diff --git a/changelog.md b/changelog.md index 00f5aa55e..e27312e42 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,18 @@ - `plugin.yml`, `paper-plugin.yml` and `bungee.yml` main class reference and validity inspection +### Changed + +- Overhauled Access Transformer support: + - many lexing errors should now be fixed + - class names and member names now have their own references, replacing the custom Goto handler + - SRG names are no longer used on NeoForge 1.20.2+ and a new copy action is available for it + - the usage inspection no longer incorrectly reports methods overridden in your code or entries covering super methods + - suppressing inspections is now possible by adding `# Suppress:AtInspectionName` after an entry or at the start of the file, or using the built-in suppress action + - added an inspection to report unresolved references, to help find out old, superfluous entries + - added an inspection to report duplicate entries in the same file + - added formatting support, class and member names are configured to align by default + ## [1.8.1] - 2024-08-10 ### Added diff --git a/src/main/grammars/AtLexer.flex b/src/main/grammars/AtLexer.flex index d5e1ad8f0..fb5e522eb 100644 --- a/src/main/grammars/AtLexer.flex +++ b/src/main/grammars/AtLexer.flex @@ -48,8 +48,9 @@ import static com.intellij.psi.TokenType.*; PRIMITIVE=[ZBCSIFDJV] CLASS_VALUE=(\[+[ZBCSIFDJ]|(\[*L[^;\n]+;)) KEYWORD_ELEMENT=(public|private|protected|default)([-+]f)? -NAME_ELEMENT=([\p{L}_\p{Sc}][\p{L}\p{N}_\p{Sc}]*)| -CLASS_NAME_ELEMENT=([\p{L}_\p{Sc}][\p{L}\p{N}_\p{Sc}]*\.)*[\p{L}_\p{Sc}][\p{L}\p{N}_\p{Sc}]* +IDENTIFIER=[\p{L}_\p{Sc}][\p{L}\p{N}_\p{Sc}]* +NAME_ELEMENT=({IDENTIFIER})| +CLASS_NAME_ELEMENT=({IDENTIFIER}*\.)*{IDENTIFIER} COMMENT=#.* CRLF=\n|\r|\r\n WHITE_SPACE=\s @@ -57,7 +58,10 @@ WHITE_SPACE=\s %% { - {KEYWORD_ELEMENT} { yybegin(CLASS_NAME); return KEYWORD_ELEMENT; } + // Force a whitespace because otherwise the keyword and class name can be right next to each other + {KEYWORD_ELEMENT}/{WHITE_SPACE} { yybegin(CLASS_NAME); return KEYWORD_ELEMENT; } + // Fallback to avoid breaking code highlighting at the keyword + {NAME_ELEMENT} { return NAME_ELEMENT; } } { @@ -73,7 +77,7 @@ WHITE_SPACE=\s "(" { return OPEN_PAREN; } ")" { return CLOSE_PAREN; } {CLASS_VALUE} { return CLASS_VALUE; } - {PRIMITIVE} ({PRIMITIVE}|{CLASS_VALUE})* { zzMarkedPos = zzStartRead + 1; return PRIMITIVE; } + {PRIMITIVE} { return PRIMITIVE; } } {CRLF} { yybegin(YYINITIAL); return CRLF; } diff --git a/src/main/grammars/AtParser.bnf b/src/main/grammars/AtParser.bnf index fcad83735..24b5d1092 100644 --- a/src/main/grammars/AtParser.bnf +++ b/src/main/grammars/AtParser.bnf @@ -32,7 +32,7 @@ elementTypeClass="com.demonwav.mcdev.platform.mcp.at.psi.AtElementType" tokenTypeClass="com.demonwav.mcdev.platform.mcp.at.psi.AtTokenType" - consumeTokenMethod="consumeTokenFast" + consumeTokenMethod(".*_recover")="consumeTokenFast" } at_file ::= line* @@ -60,7 +60,9 @@ keyword ::= KEYWORD_ELEMENT { methods=[ keywordElement="KEYWORD_ELEMENT" ] + recoverWhile=keyword_recover } +private keyword_recover ::= !(NAME_ELEMENT | CLASS_NAME_ELEMENT) class_name ::= CLASS_NAME_ELEMENT { mixin="com.demonwav.mcdev.platform.mcp.at.psi.mixins.impl.AtClassNameImplMixin" diff --git a/src/main/kotlin/platform/mcp/actions/CopyNeoForgeAtAction.kt b/src/main/kotlin/platform/mcp/actions/CopyNeoForgeAtAction.kt new file mode 100644 index 000000000..f89c9b183 --- /dev/null +++ b/src/main/kotlin/platform/mcp/actions/CopyNeoForgeAtAction.kt @@ -0,0 +1,98 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.actions + +import com.demonwav.mcdev.platform.mcp.actions.SrgActionBase.Companion.showBalloon +import com.demonwav.mcdev.platform.mcp.actions.SrgActionBase.Companion.showSuccessBalloon +import com.demonwav.mcdev.platform.mcp.at.usesSrgMemberNames +import com.demonwav.mcdev.platform.mixin.handlers.ShadowHandler +import com.demonwav.mcdev.util.descriptor +import com.demonwav.mcdev.util.getDataFromActionEvent +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.editor.Editor +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiField +import com.intellij.psi.PsiMember +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiReference +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection + +class CopyNeoForgeAtAction : AnAction() { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = isAvailable(e) + } + + private fun isAvailable(e: AnActionEvent): Boolean { + val data = getDataFromActionEvent(e) ?: return false + return data.instance.usesSrgMemberNames() == false + } + + override fun actionPerformed(e: AnActionEvent) { + val data = getDataFromActionEvent(e) ?: return + + var parent = data.element.parent + if (parent is PsiMember) { + val shadowTarget = ShadowHandler.getInstance()?.findFirstShadowTargetForReference(parent)?.element + if (shadowTarget != null) { + parent = shadowTarget + } + } + + if (parent is PsiReference) { + parent = parent.resolve() ?: return showBalloon("Not a valid element", e) + } + + when (parent) { + is PsiClass -> { + val fqn = parent.qualifiedName ?: return showBalloon("Could not find class FQN", e) + copyToClipboard(data.editor, data.element, fqn) + } + is PsiField -> { + val classFqn = parent.containingClass?.qualifiedName + ?: return showBalloon("Could not find class FQN", e) + copyToClipboard(data.editor, data.element, "$classFqn ${parent.name}") + } + is PsiMethod -> { + val classFqn = parent.containingClass?.qualifiedName + ?: return showBalloon("Could not find class FQN", e) + val methodDescriptor = parent.descriptor + ?: return showBalloon("Could not compute method descriptor", e) + copyToClipboard(data.editor, data.element, "$classFqn ${parent.name}$methodDescriptor") + } + else -> showBalloon("Not a valid element", e) + } + return + } + + private fun copyToClipboard(editor: Editor, element: PsiElement, text: String) { + val stringSelection = StringSelection(text) + val clpbrd = Toolkit.getDefaultToolkit().systemClipboard + clpbrd.setContents(stringSelection, null) + showSuccessBalloon(editor, element, "Copied $text") + } +} diff --git a/src/main/kotlin/platform/mcp/at/AtFile.kt b/src/main/kotlin/platform/mcp/at/AtFile.kt index d99d7666c..b1980e3cc 100644 --- a/src/main/kotlin/platform/mcp/at/AtFile.kt +++ b/src/main/kotlin/platform/mcp/at/AtFile.kt @@ -23,10 +23,13 @@ package com.demonwav.mcdev.platform.mcp.at import com.demonwav.mcdev.asset.PlatformAssets import com.demonwav.mcdev.facet.MinecraftFacet import com.demonwav.mcdev.platform.mcp.McpModuleType +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtEntry import com.intellij.extapi.psi.PsiFileBase import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.module.ModuleUtilCore import com.intellij.psi.FileViewProvider +import com.intellij.psi.PsiComment +import com.intellij.psi.PsiElement class AtFile(viewProvider: FileViewProvider) : PsiFileBase(viewProvider, AtLanguage) { @@ -34,6 +37,37 @@ class AtFile(viewProvider: FileViewProvider) : PsiFileBase(viewProvider, AtLangu setup() } + val headComments: List + get() { + val comments = mutableListOf() + for (child in children) { + if (child is AtEntry) { + break + } + + if (child is PsiComment) { + comments.add(child) + } + } + + return comments + } + + fun addHeadComment(text: String) { + val toAdd = text.lines().flatMap { listOf(AtElementFactory.createComment(project, it)) } + val lastHeadComment = headComments.lastOrNull() + if (lastHeadComment == null) { + for (comment in toAdd.reversed()) { + addAfter(comment, null) + } + } else { + var previousComment: PsiElement? = lastHeadComment + for (comment in toAdd) { + previousComment = addAfter(comment, previousComment) + } + } + } + private fun setup() { if (ApplicationManager.getApplication().isUnitTestMode) { return diff --git a/src/main/kotlin/platform/mcp/at/AtGotoDeclarationHandler.kt b/src/main/kotlin/platform/mcp/at/AtGotoDeclarationHandler.kt deleted file mode 100644 index a15df98a8..000000000 --- a/src/main/kotlin/platform/mcp/at/AtGotoDeclarationHandler.kt +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Minecraft Development for IntelliJ - * - * https://mcdev.io/ - * - * Copyright (C) 2024 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.mcp.at - -import com.demonwav.mcdev.facet.MinecraftFacet -import com.demonwav.mcdev.platform.mcp.McpModuleType -import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtClassName -import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtEntry -import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtFieldName -import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtFuncName -import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtFunction -import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtTypes -import com.demonwav.mcdev.util.findQualifiedClass -import com.demonwav.mcdev.util.getPrimitiveType -import com.demonwav.mcdev.util.parseClassDescriptor -import com.intellij.codeInsight.navigation.actions.GotoDeclarationHandler -import com.intellij.openapi.actionSystem.DataContext -import com.intellij.openapi.editor.Editor -import com.intellij.openapi.module.ModuleUtilCore -import com.intellij.psi.JavaPsiFacade -import com.intellij.psi.PsiElement -import com.intellij.psi.search.GlobalSearchScope - -class AtGotoDeclarationHandler : GotoDeclarationHandler { - override fun getGotoDeclarationTargets( - sourceElement: PsiElement?, - offset: Int, - editor: Editor, - ): Array? { - if (sourceElement?.language !== AtLanguage) { - return null - } - - val module = ModuleUtilCore.findModuleForPsiElement(sourceElement) ?: return null - - val instance = MinecraftFacet.getInstance(module) ?: return null - - val mcpModule = instance.getModuleOfType(McpModuleType) ?: return null - - val srgMap = mcpModule.mappingsManager?.mappingsNow ?: return null - - return when { - sourceElement.node.treeParent.elementType === AtTypes.CLASS_NAME -> { - val className = sourceElement.parent as AtClassName - val classSrgToMcp = srgMap.getMappedClass(className.classNameText) - val psiClass = findQualifiedClass(sourceElement.project, classSrgToMcp) ?: return null - arrayOf(psiClass) - } - sourceElement.node.treeParent.elementType === AtTypes.FUNC_NAME -> { - val funcName = sourceElement.parent as AtFuncName - val function = funcName.parent as AtFunction - val entry = function.parent as AtEntry - - val reference = srgMap.getMappedMethod(AtMemberReference.get(entry, function) ?: return null) - val member = reference.resolveMember(sourceElement.project) ?: return null - arrayOf(member) - } - sourceElement.node.treeParent.elementType === AtTypes.FIELD_NAME -> { - val fieldName = sourceElement.parent as AtFieldName - val entry = fieldName.parent as AtEntry - - val reference = srgMap.getMappedField(AtMemberReference.get(entry, fieldName) ?: return null) - val member = reference.resolveMember(sourceElement.project) ?: return null - arrayOf(member) - } - sourceElement.node.elementType === AtTypes.CLASS_VALUE -> { - val className = srgMap.getMappedClass(parseClassDescriptor(sourceElement.text)) - val psiClass = findQualifiedClass(sourceElement.project, className) ?: return null - arrayOf(psiClass) - } - sourceElement.node.elementType === AtTypes.PRIMITIVE -> { - val text = sourceElement.text - if (text.length != 1) { - return null - } - - val type = getPrimitiveType(text[0]) ?: return null - - val boxedType = type.boxedTypeName ?: return null - - val psiClass = JavaPsiFacade.getInstance(sourceElement.project).findClass( - boxedType, - GlobalSearchScope.allScope(sourceElement.project), - ) ?: return null - arrayOf(psiClass) - } - else -> null - } - } - - override fun getActionText(context: DataContext): String? = null -} diff --git a/src/main/kotlin/platform/mcp/at/AtParserDefinition.kt b/src/main/kotlin/platform/mcp/at/AtParserDefinition.kt index 135fba57c..dedee341e 100644 --- a/src/main/kotlin/platform/mcp/at/AtParserDefinition.kt +++ b/src/main/kotlin/platform/mcp/at/AtParserDefinition.kt @@ -43,8 +43,7 @@ class AtParserDefinition : ParserDefinition { override fun createElement(node: ASTNode): PsiElement = AtTypes.Factory.createElement(node) override fun spaceExistenceTypeBetweenTokens(left: ASTNode, right: ASTNode) = - map.entries.firstOrNull { e -> left.elementType == e.key.first || right.elementType == e.key.second }?.value - ?: ParserDefinition.SpaceRequirements.MUST_NOT + map[left.elementType to right.elementType] ?: ParserDefinition.SpaceRequirements.MUST_NOT companion object { private val COMMENTS = TokenSet.create(AtTypes.COMMENT) @@ -52,13 +51,14 @@ class AtParserDefinition : ParserDefinition { private val FILE = IFileElementType(Language.findInstance(AtLanguage::class.java)) private val map: Map, ParserDefinition.SpaceRequirements> = mapOf( - (AtTypes.KEYWORD to AtTypes.CLASS_NAME) to ParserDefinition.SpaceRequirements.MUST, - (AtTypes.CLASS_NAME to AtTypes.FIELD_NAME) to ParserDefinition.SpaceRequirements.MUST, - (AtTypes.CLASS_NAME to AtTypes.FUNCTION) to ParserDefinition.SpaceRequirements.MUST, - (AtTypes.CLASS_NAME to AtTypes.ASTERISK) to ParserDefinition.SpaceRequirements.MUST, + (AtTypes.KEYWORD_ELEMENT to AtTypes.CLASS_NAME_ELEMENT) to ParserDefinition.SpaceRequirements.MUST, + (AtTypes.CLASS_NAME_ELEMENT to AtTypes.FIELD_NAME) to ParserDefinition.SpaceRequirements.MUST, + (AtTypes.CLASS_NAME_ELEMENT to AtTypes.FUNCTION) to ParserDefinition.SpaceRequirements.MUST, + (AtTypes.CLASS_NAME_ELEMENT to AtTypes.ASTERISK_ELEMENT) to ParserDefinition.SpaceRequirements.MUST, + (AtTypes.CLASS_NAME_ELEMENT to AtTypes.COMMENT) to ParserDefinition.SpaceRequirements.MUST, (AtTypes.FIELD_NAME to AtTypes.COMMENT) to ParserDefinition.SpaceRequirements.MUST, - (AtTypes.ASTERISK to AtTypes.COMMENT) to ParserDefinition.SpaceRequirements.MUST, - (AtTypes.COMMENT to AtTypes.KEYWORD) to ParserDefinition.SpaceRequirements.MUST_LINE_BREAK, + (AtTypes.ASTERISK_ELEMENT to AtTypes.COMMENT) to ParserDefinition.SpaceRequirements.MUST, + (AtTypes.COMMENT to AtTypes.KEYWORD_ELEMENT) to ParserDefinition.SpaceRequirements.MUST_LINE_BREAK, (AtTypes.COMMENT to AtTypes.COMMENT) to ParserDefinition.SpaceRequirements.MUST_LINE_BREAK, (AtTypes.FUNCTION to AtTypes.COMMENT) to ParserDefinition.SpaceRequirements.MUST, ) diff --git a/src/main/kotlin/platform/mcp/at/AtReferenceContributor.kt b/src/main/kotlin/platform/mcp/at/AtReferenceContributor.kt new file mode 100644 index 000000000..6725921f3 --- /dev/null +++ b/src/main/kotlin/platform/mcp/at/AtReferenceContributor.kt @@ -0,0 +1,315 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at + +import com.demonwav.mcdev.facet.MinecraftFacet +import com.demonwav.mcdev.platform.mcp.McpModuleType +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtClassName +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtEntry +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtFieldName +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtFunction +import com.demonwav.mcdev.platform.mcp.at.psi.AtElement +import com.demonwav.mcdev.util.MemberReference +import com.demonwav.mcdev.util.findMethods +import com.demonwav.mcdev.util.findModule +import com.demonwav.mcdev.util.findQualifiedClass +import com.demonwav.mcdev.util.getPrimitiveWrapperClass +import com.demonwav.mcdev.util.memberReference +import com.demonwav.mcdev.util.nameAndParameterTypes +import com.demonwav.mcdev.util.qualifiedMemberReference +import com.demonwav.mcdev.util.simpleQualifiedMemberReference +import com.intellij.codeInsight.completion.InsertHandler +import com.intellij.codeInsight.completion.InsertionContext +import com.intellij.codeInsight.completion.JavaLookupElementBuilder +import com.intellij.codeInsight.completion.PrioritizedLookupElement +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.codeInsight.lookup.LookupElementBuilder +import com.intellij.openapi.util.Iconable +import com.intellij.openapi.util.TextRange +import com.intellij.patterns.PlatformPatterns.psiElement +import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiField +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiReference +import com.intellij.psi.PsiReferenceBase +import com.intellij.psi.PsiReferenceContributor +import com.intellij.psi.PsiReferenceProvider +import com.intellij.psi.PsiReferenceRegistrar +import com.intellij.util.ArrayUtil +import com.intellij.util.PlatformIcons +import com.intellij.util.ProcessingContext + +class AtReferenceContributor : PsiReferenceContributor() { + + override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) { + registrar.registerReferenceProvider(psiElement(AtClassName::class.java), AtClassNameReferenceProvider) + registrar.registerReferenceProvider(psiElement(AtFieldName::class.java), AtFieldNameReferenceProvider) + registrar.registerReferenceProvider(psiElement(AtFunction::class.java), AtFuncNameReferenceProvider) + } +} + +object AtClassNameReferenceProvider : PsiReferenceProvider() { + + override fun getReferencesByElement( + element: PsiElement, + context: ProcessingContext + ): Array { + element as AtClassName + + val references = mutableListOf() + val fqn = element.text + + var partStart = 0 + while (true) { + val partEnd = fqn.indexOf('.', partStart) + if (partEnd == -1) { + while (true) { + var outerEnd = fqn.indexOf('$', partStart) + if (outerEnd == -1) { + val range = TextRange(partStart, fqn.length) + references.add(AtClassNamePartReference(element, range, true)) + break + } else { + val range = TextRange(partStart, outerEnd) + references.add(AtClassNamePartReference(element, range, true)) + } + + partStart = outerEnd + 1 + } + + break + } else { + val range = TextRange(partStart, partEnd) + references.add(AtClassNamePartReference(element, range, false)) + } + + partStart = partEnd + 1 + } + + return references.toTypedArray() + } +} + +class AtClassNamePartReference(element: AtClassName, range: TextRange, val isClass: Boolean) : + PsiReferenceBase(element, range) { + + override fun resolve(): PsiElement? { + val project = element.project + val fqn = element.text.substring(0, rangeInElement.endOffset) + val psiFacade = JavaPsiFacade.getInstance(project) + if (isClass) { + val scope = element.resolveScope + if (fqn.contains('$')) { + val outermostClass = psiFacade.findClass(fqn.substringBefore('$'), scope) + if (outermostClass != null) { + val innerClassNames = fqn.substringAfter('$').split('$') + return innerClassNames.fold(outermostClass) { clazz, innerClassName -> + clazz.findInnerClassByName(innerClassName, false) ?: return null + } + } + } else { + val containingPackage = psiFacade.findPackage(fqn.substringBeforeLast('.')) + val clazz = containingPackage?.findClassByShortName(fqn.substringAfterLast('.'), scope)?.firstOrNull() + if (clazz != null) { + return clazz + } + } + } + + return psiFacade.findPackage(fqn) + } + + override fun getVariants(): Array { + val project = element.project + val text = element.text + if (text.contains('$')) { + val classFqn = text.substringBeforeLast('$').replace('$', '.') + val scope = element.resolveScope + val clazz = JavaPsiFacade.getInstance(project).findClass(classFqn, scope) + if (clazz != null) { + return clazz.allInnerClasses.mapNotNull { JavaLookupElementBuilder.forClass(it) }.toTypedArray() + } + } else { + val packFqn = text.substringBeforeLast('.') + val pack = JavaPsiFacade.getInstance(project).findPackage(packFqn) + if (pack != null) { + val elements = mutableListOf() + pack.classes.filter { it.name != "package-info" } + .mapNotNullTo(elements) { JavaLookupElementBuilder.forClass(it) } + pack.subPackages.mapNotNullTo(elements) { subPackage -> + LookupElementBuilder.create(subPackage) + .withIcon(subPackage.getIcon(Iconable.ICON_FLAG_VISIBILITY)) + } + return elements.toTypedArray() + } + } + + return ArrayUtil.EMPTY_STRING_ARRAY + } +} + +abstract class AtClassMemberReference(element: E, range: TextRange) : + PsiReferenceBase(element, range) { + + override fun getVariants(): Array { + val entry = element.parent as? AtEntry ?: return ArrayUtil.EMPTY_OBJECT_ARRAY + + val module = element.findModule() ?: return ArrayUtil.EMPTY_OBJECT_ARRAY + val instance = MinecraftFacet.getInstance(module) ?: return ArrayUtil.EMPTY_OBJECT_ARRAY + val useSrg = instance.usesSrgMemberNames() == true + val (mapField, mapMethod) = if (!useSrg) { + { it: PsiField -> it.memberReference } to { it: PsiMethod -> it.memberReference } + } else { + val mcpModule = instance.getModuleOfType(McpModuleType)!! + val srgMap = mcpModule.mappingsManager?.mappingsNow ?: return ArrayUtil.EMPTY_OBJECT_ARRAY + { it: PsiField -> srgMap.getIntermediaryField(it) } to { it: PsiMethod -> srgMap.getIntermediaryMethod(it) } + } + + val results = mutableListOf() + + val entryClass = entry.className.classNameValue ?: return ArrayUtil.EMPTY_OBJECT_ARRAY + for (field in entryClass.fields) { + val memberReference = mapField(field) ?: field.simpleQualifiedMemberReference + val lookupElement = LookupElementBuilder.create(memberReference.name) + .withLookupStrings(listOf(field.name)) // Some fields don't appear in completion without this + .withPsiElement(field) + .withPresentableText(field.name) + .withIcon(PlatformIcons.FIELD_ICON) + .withTailText(" (${memberReference.name})".takeIf { useSrg }, true) + .withInsertHandler(AtClassMemberInsertionHandler(field.name.takeIf { useSrg })) + results.add(PrioritizedLookupElement.withPriority(lookupElement, 1.0)) + } + + for (method in entryClass.methods) { + val memberReference = mapMethod(method) ?: method.qualifiedMemberReference + val lookupElement = LookupElementBuilder.create(memberReference.name + memberReference.descriptor) + .withLookupStrings(listOf(method.name)) // For symmetry with fields, might happen too + .withPsiElement(method) + .withPresentableText(method.nameAndParameterTypes) + .withIcon(PlatformIcons.METHOD_ICON) + .withTailText(" (${memberReference.name})".takeIf { useSrg }, true) + .withInsertHandler(AtClassMemberInsertionHandler(method.name.takeIf { useSrg })) + results.add(PrioritizedLookupElement.withPriority(lookupElement, 0.0)) + } + + return results.toTypedArray() + } +} + +object AtFieldNameReferenceProvider : PsiReferenceProvider() { + + override fun getReferencesByElement( + element: PsiElement, + context: ProcessingContext + ): Array = arrayOf(AtFieldNameReference(element as AtFieldName)) +} + +class AtFieldNameReference(element: AtFieldName) : + AtClassMemberReference(element, TextRange(0, element.text.length)) { + + override fun resolve(): PsiElement? { + val entry = element.parent as? AtEntry ?: return null + val entryClass = entry.className?.classNameValue ?: return null + + val module = element.findModule() ?: return null + val instance = MinecraftFacet.getInstance(module) ?: return null + val mcpModule = instance.getModuleOfType(McpModuleType) ?: return null + + return if (instance.usesSrgMemberNames() != true) { + entryClass.findFieldByName(element.text, false) + } else { + val srgMap = mcpModule.mappingsManager?.mappingsNow ?: return null + val reference = srgMap.getMappedField(AtMemberReference.get(entry, element) ?: return null) + reference.resolveMember(module.project) + } + } +} + +object AtFuncNameReferenceProvider : PsiReferenceProvider() { + + override fun getReferencesByElement( + element: PsiElement, + context: ProcessingContext + ): Array { + val func = element as AtFunction + val references = mutableListOf(AtFuncNameReference(func)) + + element.argumentList.mapTo(references) { AtClassValueReference(func, it) } + + references.add(AtClassValueReference(element, element.returnValue)) + + return references.toTypedArray() + } +} + +class AtFuncNameReference(element: AtFunction) : + AtClassMemberReference(element, element.funcName.textRangeInParent) { + + override fun resolve(): PsiElement? { + val entry = element.parent as? AtEntry ?: return null + val entryClass = entry.className?.classNameValue ?: return null + + val module = element.findModule() ?: return null + val instance = MinecraftFacet.getInstance(module) ?: return null + val mcpModule = instance.getModuleOfType(McpModuleType) ?: return null + + return if (instance.usesSrgMemberNames() != true) { + val memberReference = MemberReference.parse(element.text) ?: return null + entryClass.findMethods(memberReference).firstOrNull() + } else { + val srgMap = mcpModule.mappingsManager?.mappingsNow ?: return null + val reference = srgMap.getMappedMethod(AtMemberReference.get(entry, element) ?: return null) + reference.resolveMember(module.project) + } + } +} + +class AtClassValueReference(val element: AtFunction, val argument: AtElement) : + PsiReferenceBase(element, argument.textRangeInParent, false) { + + override fun resolve(): PsiElement? { + val text = argument.text.substringAfterLast('[') + return when (val c = text[0]) { + 'L' -> if (!text.contains('.')) { + findQualifiedClass(element.project, text.substring(1, text.length - 1).replace('/', '.')) + } else { + null + } + + else -> getPrimitiveWrapperClass(c, element.project) + } + } +} + +private class AtClassMemberInsertionHandler(val memberName: String?) : InsertHandler { + + override fun handleInsert(context: InsertionContext, item: LookupElement) { + val line = context.document.getLineNumber(context.tailOffset) + context.document.deleteString(context.tailOffset, context.document.getLineEndOffset(line)) + + if (memberName != null) { + val comment = " # $memberName" + context.document.insertString(context.editor.caretModel.offset, comment) + context.editor.caretModel.moveCaretRelatively(comment.length, 0, false, false, false) + } + } +} diff --git a/src/main/kotlin/platform/mcp/at/AtUsageInspection.kt b/src/main/kotlin/platform/mcp/at/AtUsageInspection.kt deleted file mode 100644 index ccba93f39..000000000 --- a/src/main/kotlin/platform/mcp/at/AtUsageInspection.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Minecraft Development for IntelliJ - * - * https://mcdev.io/ - * - * Copyright (C) 2024 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.mcp.at - -import com.demonwav.mcdev.facet.MinecraftFacet -import com.demonwav.mcdev.platform.mcp.McpModuleType -import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtEntry -import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtFieldName -import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtFunction -import com.intellij.codeInspection.LocalInspectionTool -import com.intellij.codeInspection.ProblemHighlightType -import com.intellij.codeInspection.ProblemsHolder -import com.intellij.openapi.module.ModuleUtilCore -import com.intellij.psi.PsiElement -import com.intellij.psi.PsiElementVisitor -import com.intellij.psi.search.GlobalSearchScope -import com.intellij.psi.search.searches.ReferencesSearch - -class AtUsageInspection : LocalInspectionTool() { - - override fun getStaticDescription(): String { - return "The declared access transformer is never used" - } - - override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor { - return object : PsiElementVisitor() { - override fun visitElement(element: PsiElement) { - if (element !is AtEntry) { - return - } - - val module = ModuleUtilCore.findModuleForPsiElement(element) ?: return - val instance = MinecraftFacet.getInstance(module) ?: return - val mcpModule = instance.getModuleOfType(McpModuleType) ?: return - val srgMap = mcpModule.mappingsManager?.mappingsNow ?: return - - val member = element.function ?: element.fieldName ?: return - val reference = AtMemberReference.get(element, member) ?: return - - val psi = when (member) { - is AtFunction -> - reference.resolveMember(element.project) ?: srgMap.tryGetMappedMethod(reference)?.resolveMember( - element.project, - ) ?: return - is AtFieldName -> - reference.resolveMember(element.project) - ?: srgMap.tryGetMappedField(reference)?.resolveMember(element.project) ?: return - else -> - return - } - - val query = ReferencesSearch.search(psi, GlobalSearchScope.projectScope(element.project)) - query.findFirst() - ?: holder.registerProblem( - element, - "Access Transformer entry is never used", - ProblemHighlightType.LIKE_UNUSED_SYMBOL, - ) - } - } - } -} diff --git a/src/main/kotlin/platform/mcp/at/at-utils.kt b/src/main/kotlin/platform/mcp/at/at-utils.kt new file mode 100644 index 000000000..dcfddd696 --- /dev/null +++ b/src/main/kotlin/platform/mcp/at/at-utils.kt @@ -0,0 +1,38 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at + +import com.demonwav.mcdev.facet.MinecraftFacet +import com.demonwav.mcdev.platform.forge.ForgeModuleType +import com.demonwav.mcdev.platform.mcp.McpModuleType +import com.demonwav.mcdev.platform.neoforge.NeoForgeModuleType +import com.demonwav.mcdev.util.MinecraftVersions +import com.demonwav.mcdev.util.SemanticVersion + +fun MinecraftFacet.usesSrgMemberNames(): Boolean? { + if (!this.isOfType(NeoForgeModuleType)) { + return this.isOfType(ForgeModuleType) + } + + val mcpModule = this.getModuleOfType(McpModuleType) ?: return null + val mcVersion = mcpModule.getSettings().minecraftVersion?.let(SemanticVersion::tryParse) ?: return null + return mcVersion < MinecraftVersions.MC1_20_2 +} diff --git a/src/main/kotlin/platform/mcp/at/completion/AtCompletionContributor.kt b/src/main/kotlin/platform/mcp/at/completion/AtCompletionContributor.kt index 48ebeb794..140a16b70 100644 --- a/src/main/kotlin/platform/mcp/at/completion/AtCompletionContributor.kt +++ b/src/main/kotlin/platform/mcp/at/completion/AtCompletionContributor.kt @@ -20,41 +20,29 @@ package com.demonwav.mcdev.platform.mcp.at.completion -import com.demonwav.mcdev.facet.MinecraftFacet -import com.demonwav.mcdev.platform.mcp.McpModuleType -import com.demonwav.mcdev.platform.mcp.at.AtElementFactory +import com.demonwav.mcdev.platform.mcp.at.AtElementFactory.Keyword import com.demonwav.mcdev.platform.mcp.at.AtLanguage import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtEntry -import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtFieldName -import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtFunction import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtTypes -import com.demonwav.mcdev.util.anonymousElements import com.demonwav.mcdev.util.fullQualifiedName -import com.demonwav.mcdev.util.getSimilarity -import com.demonwav.mcdev.util.nameAndParameterTypes -import com.demonwav.mcdev.util.qualifiedMemberReference -import com.demonwav.mcdev.util.simpleQualifiedMemberReference import com.intellij.codeInsight.completion.CompletionContributor import com.intellij.codeInsight.completion.CompletionParameters import com.intellij.codeInsight.completion.CompletionResultSet import com.intellij.codeInsight.completion.CompletionType -import com.intellij.codeInsight.completion.CompletionUtil -import com.intellij.codeInsight.completion.PrioritizedLookupElement +import com.intellij.codeInsight.completion.JavaLookupElementBuilder import com.intellij.codeInsight.lookup.LookupElementBuilder -import com.intellij.openapi.module.ModuleUtilCore -import com.intellij.patterns.PlatformPatterns.elementType import com.intellij.patterns.PlatformPatterns.psiElement import com.intellij.patterns.PsiElementPattern import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.JavaRecursiveElementVisitor +import com.intellij.psi.PsiAnonymousClass import com.intellij.psi.PsiClass -import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiElement -import com.intellij.psi.search.GlobalSearchScope -import com.intellij.psi.search.PsiShortNamesCache +import com.intellij.psi.PsiPackage +import com.intellij.psi.TokenType import com.intellij.psi.tree.IElementType -import com.intellij.psi.tree.TokenSet import com.intellij.psi.util.PsiUtilCore -import com.intellij.util.PlatformIcons +import com.intellij.psi.util.parentOfType class AtCompletionContributor : CompletionContributor() { @@ -69,279 +57,46 @@ class AtCompletionContributor : CompletionContributor() { } val parent = position.parent - - val parentText = parent.text ?: return - if (parentText.length < CompletionUtil.DUMMY_IDENTIFIER.length) { - return - } - val text = parentText.substring(0, parentText.length - CompletionUtil.DUMMY_IDENTIFIER.length) - when { - AFTER_KEYWORD.accepts(parent) -> handleAtClassName(text, parent, result) - AFTER_CLASS_NAME.accepts(parent) -> handleAtName(text, parent, result) - AFTER_NEWLINE.accepts(parent) -> handleNewLine(text, result) + AFTER_KEYWORD.accepts(parent) -> completeAtClassName(parent, result) + position.parentOfType() == null -> completeKeywords(result) } } - private fun handleAtClassName(text: String, element: PsiElement, result: CompletionResultSet) { - if (text.isEmpty()) { - return - } - - val currentPackage = text.substringBeforeLast('.', "") - val beginning = text.substringAfterLast('.', "") - - if (currentPackage == "" || beginning == "") { - return - } - - val module = ModuleUtilCore.findModuleForPsiElement(element) ?: return - val scope = GlobalSearchScope.moduleWithDependenciesAndLibrariesScope(module) - val project = module.project - - // Short name completion - if (!text.contains('.')) { - val kindResult = result.withPrefixMatcher(KindPrefixMatcher(text)) - val cache = PsiShortNamesCache.getInstance(project) - - var counter = 0 - for (className in cache.allClassNames) { - if (!className.contains(beginning, ignoreCase = true)) { - continue - } - - if (counter++ > 1000) { - break // Prevent insane CPU usage - } - - val classesByName = cache.getClassesByName(className, scope) - for (classByName in classesByName) { - val name = classByName.fullQualifiedName ?: continue - kindResult.addElement( - PrioritizedLookupElement.withPriority( - LookupElementBuilder.create(name).withIcon(PlatformIcons.CLASS_ICON), - 1.0 + name.getValue(beginning), - ), - ) - } - } - } - - // Anonymous and inner class completion - if (text.contains('$')) { - val currentClass = - JavaPsiFacade.getInstance(project).findClass(text.substringBeforeLast('$'), scope) ?: return - - for (innerClass in currentClass.allInnerClasses) { - if (innerClass.name?.contains(beginning.substringAfterLast('$'), ignoreCase = true) != true) { - continue - } - - val name = innerClass.fullQualifiedName ?: continue - result.addElement( - PrioritizedLookupElement.withPriority( - LookupElementBuilder.create(name).withIcon(PlatformIcons.CLASS_ICON), - 1.0, - ), - ) - } - - for (anonymousElement in currentClass.anonymousElements) { - val anonClass = anonymousElement as? PsiClass ?: continue - - val name = anonClass.fullQualifiedName ?: continue - result.addElement( - PrioritizedLookupElement.withPriority( - LookupElementBuilder.create(name).withIcon(PlatformIcons.CLASS_ICON), - 1.0, - ), - ) - } - return - } - - val psiPackage = JavaPsiFacade.getInstance(project).findPackage(currentPackage) ?: return - - // Classes in package completion - val used = mutableSetOf() - for (psiClass in psiPackage.classes) { - if (psiClass.name == null) { - continue - } - - if (!psiClass.name!!.contains(beginning, ignoreCase = true) || psiClass.name == "package-info") { - continue - } - - if (!used.add(psiClass.name!!)) { - continue - } - - val name = psiClass.fullQualifiedName ?: continue - result.addElement( - PrioritizedLookupElement.withPriority( - LookupElementBuilder.create(name).withIcon(PlatformIcons.CLASS_ICON), - 1.0, - ), - ) - } - used.clear() // help GC - - // Packages in package completion - for (subPackage in psiPackage.subPackages) { - if (subPackage.name == null) { - continue - } - - if (!subPackage.name!!.contains(beginning, ignoreCase = true)) { - continue - } - - val name = subPackage.qualifiedName - result.addElement( - PrioritizedLookupElement.withPriority( - LookupElementBuilder.create(name).withIcon(PlatformIcons.PACKAGE_ICON), - 0.0, - ), - ) - } + private fun completeKeywords(result: CompletionResultSet) { + result.addAllElements(Keyword.entries.map { LookupElementBuilder.create(it.text) }) } - private fun handleAtName(text: String, memberName: PsiElement, result: CompletionResultSet) { - if (memberName !is AtFieldName) { + private fun completeAtClassName(element: PsiElement, result: CompletionResultSet) { + if (element.textContains('.')) { + // Only complete "empty" class names here, the rest is handled by the reference variants return } - val entry = memberName.parent as? AtEntry ?: return - - val entryClass = entry.className?.classNameValue ?: return - - val module = ModuleUtilCore.findModuleForPsiElement(memberName) ?: return - val project = module.project - - val mcpModule = MinecraftFacet.getInstance(module)?.getModuleOfType(McpModuleType) ?: return - - val srgMap = mcpModule.mappingsManager?.mappingsNow ?: return - - val srgResult = result.withPrefixMatcher(SrgPrefixMatcher(text)) - - for (field in entryClass.fields) { - if (!field.name.contains(text, ignoreCase = true)) { - continue + val mcPackage = JavaPsiFacade.getInstance(element.project).findPackage("net.minecraft") ?: return + mcPackage.accept(object : JavaRecursiveElementVisitor() { + override fun visitPackage(aPackage: PsiPackage) { + aPackage.subPackages.forEach { it.accept(this) } + aPackage.classes.forEach { it.accept(this); it.acceptChildren(this) } } - val memberReference = srgMap.getIntermediaryField(field) ?: field.simpleQualifiedMemberReference - srgResult.addElement( - PrioritizedLookupElement.withPriority( - LookupElementBuilder - .create(field.name) - .withIcon(PlatformIcons.FIELD_ICON) - .withTailText(" (${memberReference.name})", true) - .withInsertHandler handler@{ context, _ -> - val currentElement = context.file.findElementAt(context.startOffset) ?: return@handler - currentElement.replace( - AtElementFactory.createFieldName( - context.project, - memberReference.name, - ), - ) - - // TODO: Fix visibility decrease - PsiDocumentManager.getInstance(context.project) - .doPostponedOperationsAndUnblockDocument(context.document) - val comment = " # ${field.name}" - context.document.insertString(context.editor.caretModel.offset, comment) - context.editor.caretModel.moveCaretRelatively(comment.length, 0, false, false, false) - }, - 1.0, - ), - ) - } + override fun visitClass(aClass: PsiClass) { + if (aClass !is PsiAnonymousClass) { + val fqn = aClass.fullQualifiedName + if (fqn != null) { + result.addElement(JavaLookupElementBuilder.forClass(aClass, fqn)) + } + } - for (method in entryClass.methods) { - if (!method.name.contains(text, ignoreCase = true)) { - continue + super.visitClass(aClass) } - - val memberReference = srgMap.getIntermediaryMethod(method) ?: method.qualifiedMemberReference - srgResult.addElement( - PrioritizedLookupElement.withPriority( - LookupElementBuilder.create(method.nameAndParameterTypes) - .withIcon(PlatformIcons.METHOD_ICON) - .withTailText(" (${memberReference.name})", true) - .withInsertHandler handler@{ context, _ -> - var currentElement = context.file.findElementAt(context.startOffset) ?: return@handler - var counter = 0 - while (currentElement !is AtFieldName && currentElement !is AtFunction) { - currentElement = currentElement.parent - if (counter++ > 3) { - break - } - } - - // Hopefully this won't happen lol - if (currentElement !is AtFieldName && currentElement !is AtFunction) { - return@handler - } - - if (currentElement is AtFieldName) { - // get rid of the bad parameters - val parent = currentElement.parent - val children = - parent.node.getChildren(TokenSet.create(AtTypes.OPEN_PAREN, AtTypes.CLOSE_PAREN)) - if (children.size == 2) { - parent.node.removeRange(children[0], children[1].treeNext) - } - } - - currentElement.replace( - AtElementFactory.createFunction( - project, - memberReference.name + memberReference.descriptor, - ), - ) - - // TODO: Fix visibility decreases - PsiDocumentManager.getInstance(context.project) - .doPostponedOperationsAndUnblockDocument(context.document) - val comment = " # ${method.name}" - context.document.insertString(context.editor.caretModel.offset, comment) - context.editor.caretModel.moveCaretRelatively(comment.length, 0, false, false, false) - }, - 0.0, - ), - ) - } - } - - private fun handleNewLine(text: String, result: CompletionResultSet) { - for (keyword in AtElementFactory.Keyword.softMatch(text)) { - result.addElement(LookupElementBuilder.create(keyword.text)) - } - } - - /** - * This helps order the (hopefully) most relevant entries in the short name completion - */ - private fun String?.getValue(text: String): Int { - if (this == null) { - return 0 - } - - // Push net.minecraft{forge} classes up to the top - val packageBonus = if (this.startsWith("net.minecraft")) 10_000 else 0 - - val thisName = this.substringAfterLast('.') - - return thisName.getSimilarity(text, packageBonus) + }) } companion object { fun after(type: IElementType): PsiElementPattern.Capture = - psiElement().afterSibling(psiElement().withElementType(elementType().oneOf(type))) + psiElement().afterSiblingSkipping(psiElement(TokenType.WHITE_SPACE), psiElement(type)) val AFTER_KEYWORD = after(AtTypes.KEYWORD) - val AFTER_CLASS_NAME = after(AtTypes.CLASS_NAME) - val AFTER_NEWLINE = after(AtTypes.CRLF) } } diff --git a/src/main/kotlin/platform/mcp/at/format/AtBlock.kt b/src/main/kotlin/platform/mcp/at/format/AtBlock.kt new file mode 100644 index 000000000..8fc9748be --- /dev/null +++ b/src/main/kotlin/platform/mcp/at/format/AtBlock.kt @@ -0,0 +1,105 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at.format + +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtTypes +import com.demonwav.mcdev.util.children +import com.intellij.formatting.Alignment +import com.intellij.formatting.Block +import com.intellij.formatting.Indent +import com.intellij.formatting.Spacing +import com.intellij.formatting.SpacingBuilder +import com.intellij.formatting.Wrap +import com.intellij.lang.ASTNode +import com.intellij.psi.TokenType +import com.intellij.psi.codeStyle.CodeStyleSettings +import com.intellij.psi.formatter.common.AbstractBlock +import com.intellij.psi.tree.IFileElementType + +class AtBlock( + node: ASTNode, + wrap: Wrap?, + alignment: Alignment?, + val spacingBuilder: SpacingBuilder, + val codeStyleSettings: CodeStyleSettings, + val entryClassAlignment: Alignment? = null, + val entryMemberAlignment: Alignment? = null, +) : AbstractBlock(node, wrap, alignment) { + + override fun buildChildren(): List { + val blocks = mutableListOf() + + var entryClassAlignment: Alignment? = entryClassAlignment + var entryMemberAlignment: Alignment? = entryMemberAlignment + + var newlineCount = 0 + val alignGroups = node.elementType is IFileElementType && + codeStyleSettings.getCustomSettings(AtCodeStyleSettings::class.java).ALIGN_ENTRY_CLASS_AND_MEMBER + for (child in node.children()) { + val childType = child.elementType + if (childType == TokenType.WHITE_SPACE) { + continue + } + + if (alignGroups) { + if (childType == AtTypes.CRLF) { + newlineCount++ + continue + } else if (childType != AtTypes.COMMENT) { + if (newlineCount >= 2) { + // Align different groups separately, comments are not counted towards any group + entryClassAlignment = Alignment.createAlignment(true) + entryMemberAlignment = Alignment.createAlignment(true) + } + newlineCount = 0 + } + } + + val alignment = when (childType) { + AtTypes.CLASS_NAME -> entryClassAlignment + AtTypes.FIELD_NAME, AtTypes.FUNCTION, AtTypes.ASTERISK -> entryMemberAlignment + else -> null + } + + blocks.add( + AtBlock( + child, + null, + alignment, + spacingBuilder, + codeStyleSettings, + entryClassAlignment, + entryMemberAlignment + ) + ) + } + + return blocks + } + + override fun getIndent(): Indent? = Indent.getNoneIndent() + + override fun getChildIndent(): Indent? = Indent.getNoneIndent() + + override fun getSpacing(child1: Block?, child2: Block): Spacing? = spacingBuilder.getSpacing(this, child1, child2) + + override fun isLeaf(): Boolean = node.firstChildNode == null +} diff --git a/src/main/kotlin/platform/mcp/at/format/AtCodeStyleSettings.kt b/src/main/kotlin/platform/mcp/at/format/AtCodeStyleSettings.kt new file mode 100644 index 000000000..a4af706f0 --- /dev/null +++ b/src/main/kotlin/platform/mcp/at/format/AtCodeStyleSettings.kt @@ -0,0 +1,97 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at.format + +import com.demonwav.mcdev.platform.mcp.at.AtLanguage +import com.intellij.application.options.CodeStyleAbstractConfigurable +import com.intellij.application.options.CodeStyleAbstractPanel +import com.intellij.application.options.TabbedLanguageCodeStylePanel +import com.intellij.lang.Language +import com.intellij.openapi.util.NlsContexts +import com.intellij.psi.codeStyle.CodeStyleConfigurable +import com.intellij.psi.codeStyle.CodeStyleSettings +import com.intellij.psi.codeStyle.CodeStyleSettingsCustomizable +import com.intellij.psi.codeStyle.CodeStyleSettingsProvider +import com.intellij.psi.codeStyle.CustomCodeStyleSettings +import com.intellij.psi.codeStyle.LanguageCodeStyleSettingsProvider + +class AtCodeStyleSettings(val settings: CodeStyleSettings) : CustomCodeStyleSettings("AtCodeStyleSettings", settings) { + @JvmField + var SPACE_BEFORE_ENTRY_COMMENT = true + + @JvmField + var ALIGN_ENTRY_CLASS_AND_MEMBER = true +} + +class AtCodeStyleSettingsProvider : CodeStyleSettingsProvider() { + override fun createCustomSettings(settings: CodeStyleSettings): CustomCodeStyleSettings = + AtCodeStyleSettings(settings) + + override fun getConfigurableDisplayName(): @NlsContexts.ConfigurableName String? = AtLanguage.displayName + + override fun createConfigurable( + settings: CodeStyleSettings, + modelSettings: CodeStyleSettings + ): CodeStyleConfigurable { + return object : CodeStyleAbstractConfigurable(settings, modelSettings, configurableDisplayName) { + override fun createPanel(settings: CodeStyleSettings): CodeStyleAbstractPanel { + return AtCodeStyleSettingsConfigurable(currentSettings, settings) + } + } + } +} + +class AtCodeStyleSettingsConfigurable(currentSettings: CodeStyleSettings, settings: CodeStyleSettings) : + TabbedLanguageCodeStylePanel(AtLanguage, currentSettings, settings) + +class AtLanguageCodeStyleSettingsProvider : LanguageCodeStyleSettingsProvider() { + + override fun getLanguage(): Language = AtLanguage + + override fun customizeSettings(consumer: CodeStyleSettingsCustomizable, settingsType: SettingsType) { + if (settingsType == SettingsType.SPACING_SETTINGS) { + consumer.showCustomOption( + AtCodeStyleSettings::class.java, + "SPACE_BEFORE_ENTRY_COMMENT", + "Space before entry comment", + "Spacing and alignment" + ) + consumer.showCustomOption( + AtCodeStyleSettings::class.java, + "ALIGN_ENTRY_CLASS_AND_MEMBER", + "Align entry class name and member", + "Spacing and alignment" + ) + } + } + + override fun getCodeSample(settingsType: SettingsType): String? = """ + # Some header comment + + public net.minecraft.client.Minecraft pickBlock()V# This is an entry comment + public net.minecraft.client.Minecraft userProperties()Lcom/mojang/authlib/minecraft/UserApiService${'$'}UserProperties; + + # Each group can be aligned independently + protected net.minecraft.client.gui.screens.inventory.AbstractContainerScreen clickedSlot + protected-f net.minecraft.client.gui.screens.inventory.AbstractContainerScreen playerInventoryTitle + protected net.minecraft.client.gui.screens.inventory.AbstractContainerScreen findSlot(DD)Lnet/minecraft/world/inventory/Slot; + """.trimIndent() +} diff --git a/src/main/kotlin/platform/mcp/at/format/AtFormattingModelBuilder.kt b/src/main/kotlin/platform/mcp/at/format/AtFormattingModelBuilder.kt new file mode 100644 index 000000000..a15db9f86 --- /dev/null +++ b/src/main/kotlin/platform/mcp/at/format/AtFormattingModelBuilder.kt @@ -0,0 +1,63 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at.format + +import com.demonwav.mcdev.platform.mcp.at.AtLanguage +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtTypes +import com.intellij.formatting.Alignment +import com.intellij.formatting.FormattingContext +import com.intellij.formatting.FormattingModel +import com.intellij.formatting.FormattingModelBuilder +import com.intellij.formatting.FormattingModelProvider +import com.intellij.formatting.SpacingBuilder +import com.intellij.psi.codeStyle.CodeStyleSettings + +class AtFormattingModelBuilder : FormattingModelBuilder { + + private fun createSpaceBuilder(settings: CodeStyleSettings): SpacingBuilder { + val atSettings = settings.getCustomSettings(AtCodeStyleSettings::class.java) + return SpacingBuilder(settings, AtLanguage) + .between(AtTypes.ENTRY, AtTypes.COMMENT).spaceIf(atSettings.SPACE_BEFORE_ENTRY_COMMENT) + // Removes alignment spaces if it is disabled + .between(AtTypes.KEYWORD, AtTypes.CLASS_NAME).spaces(1) + .between(AtTypes.CLASS_NAME, AtTypes.FIELD_NAME).spaces(1) + .between(AtTypes.CLASS_NAME, AtTypes.FUNCTION).spaces(1) + .between(AtTypes.CLASS_NAME, AtTypes.ASTERISK).spaces(1) + } + + override fun createModel(formattingContext: FormattingContext): FormattingModel { + val codeStyleSettings = formattingContext.codeStyleSettings + val rootBlock = AtBlock( + formattingContext.node, + null, + null, + createSpaceBuilder(codeStyleSettings), + codeStyleSettings, + Alignment.createAlignment(true), + Alignment.createAlignment(true), + ) + return FormattingModelProvider.createFormattingModelForPsiFile( + formattingContext.containingFile, + rootBlock, + codeStyleSettings + ) + } +} diff --git a/src/main/kotlin/platform/mcp/at/inspections/AtDuplicateEntryInspection.kt b/src/main/kotlin/platform/mcp/at/inspections/AtDuplicateEntryInspection.kt new file mode 100644 index 000000000..a4c4160b1 --- /dev/null +++ b/src/main/kotlin/platform/mcp/at/inspections/AtDuplicateEntryInspection.kt @@ -0,0 +1,49 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at.inspections + +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtEntry +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtVisitor +import com.demonwav.mcdev.util.childrenOfType +import com.intellij.codeInspection.LocalInspectionTool +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.psi.PsiElementVisitor + +class AtDuplicateEntryInspection : LocalInspectionTool() { + + override fun getStaticDescription(): String? = "Reports duplicate AT entries in the same file" + + override fun buildVisitor( + holder: ProblemsHolder, + isOnTheFly: Boolean + ): PsiElementVisitor = object : AtVisitor() { + + override fun visitEntry(entry: AtEntry) { + // Either a MemberReference or the class name text for class-level entries + val entryMemberReference = entry.memberReference ?: entry.className.text + val allMemberReferences = entry.containingFile.childrenOfType() + .map { it.memberReference ?: it.className.text } + if (allMemberReferences.count { it == entryMemberReference } > 1) { + holder.registerProblem(entry, "Duplicate entry", RemoveAtEntryFix.forWholeLine(entry, false)) + } + } + } +} diff --git a/src/main/kotlin/platform/mcp/at/inspections/AtInspectionSuppressor.kt b/src/main/kotlin/platform/mcp/at/inspections/AtInspectionSuppressor.kt new file mode 100644 index 000000000..eca8fe115 --- /dev/null +++ b/src/main/kotlin/platform/mcp/at/inspections/AtInspectionSuppressor.kt @@ -0,0 +1,145 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at.inspections + +import com.demonwav.mcdev.platform.mcp.at.AtElementFactory +import com.demonwav.mcdev.platform.mcp.at.AtFile +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtEntry +import com.intellij.codeInspection.InspectionSuppressor +import com.intellij.codeInspection.LocalQuickFixOnPsiElement +import com.intellij.codeInspection.SuppressQuickFix +import com.intellij.codeInspection.util.IntentionFamilyName +import com.intellij.codeInspection.util.IntentionName +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.psi.util.parentOfType + +class AtInspectionSuppressor : InspectionSuppressor { + + override fun isSuppressedFor(element: PsiElement, toolId: String): Boolean { + val entry = element.parentOfType(withSelf = true) ?: return false + val entryComment = entry.commentText + if (entryComment != null) { + if (isSuppressing(entryComment, toolId)) { + return true + } + } + + val file = element.containingFile as AtFile + return file.headComments.any { comment -> isSuppressing(comment.text, toolId) } + } + + private fun isSuppressing(entryComment: String, toolId: String): Boolean { + val suppressed = entryComment.substringAfter("Suppress:").substringBefore(' ').split(',') + return toolId in suppressed + } + + override fun getSuppressActions( + element: PsiElement?, + toolId: String + ): Array { + if (element == null) { + return SuppressQuickFix.EMPTY_ARRAY + } + + val entry = element as? AtEntry + ?: element.parentOfType(withSelf = true) + ?: PsiTreeUtil.getPrevSiblingOfType(element, AtEntry::class.java) // For when we are at a CRLF + return if (entry != null) { + arrayOf(AtSuppressQuickFix(entry, toolId), AtSuppressQuickFix(element.containingFile, toolId)) + } else { + arrayOf(AtSuppressQuickFix(element.containingFile, toolId)) + } + } + + class AtSuppressQuickFix(element: PsiElement, val toolId: String) : + LocalQuickFixOnPsiElement(element), SuppressQuickFix { + + override fun getText(): @IntentionName String = when (startElement) { + is AtEntry -> "Suppress $toolId for entry" + is AtFile -> "Suppress $toolId for file" + else -> "Suppress $toolId" + } + + override fun getFamilyName(): @IntentionFamilyName String = "Suppress inspection" + + override fun invoke( + project: Project, + file: PsiFile, + startElement: PsiElement, + endElement: PsiElement + ) { + when (startElement) { + is AtEntry -> suppressForEntry(startElement) + is AtFile -> suppressForFile(startElement) + } + } + + private fun suppressForEntry(entry: AtEntry) { + val commentText = entry.commentText?.trim() + if (commentText == null) { + entry.setComment("Suppress:$toolId") + return + } + + val suppressStart = commentText.indexOf("Suppress:") + if (suppressStart == -1) { + entry.setComment("Suppress:$toolId $commentText") + return + } + + val suppressEnd = commentText.indexOf(' ', suppressStart).takeUnless { it == -1 } ?: commentText.length + val newComment = + commentText.substring(suppressStart, suppressEnd) + ",$toolId" + commentText.substring(suppressEnd) + entry.setComment(newComment) + } + + private fun suppressForFile(file: AtFile) { + val existingSuppressComment = file.headComments.firstOrNull { it.text.contains("Suppress:") } + if (existingSuppressComment == null) { + file.addHeadComment("Suppress:$toolId") + return + } + + val commentText = existingSuppressComment.text + val suppressStart = commentText.indexOf("Suppress:") + if (suppressStart == -1) { + file.addHeadComment("Suppress:$toolId") + return + } + + val suppressEnd = commentText.indexOf(' ', suppressStart).takeUnless { it == -1 } ?: commentText.length + val newCommentText = + commentText.substring(suppressStart, suppressEnd) + ",$toolId" + commentText.substring(suppressEnd) + val newComment = AtElementFactory.createComment(file.project, newCommentText) + existingSuppressComment.replace(newComment) + } + + override fun isAvailable( + project: Project, + context: PsiElement + ): Boolean = context.isValid + + override fun isSuppressAll(): Boolean = false + } +} diff --git a/src/main/kotlin/platform/mcp/at/inspections/AtUnresolvedReferenceInspection.kt b/src/main/kotlin/platform/mcp/at/inspections/AtUnresolvedReferenceInspection.kt new file mode 100644 index 000000000..9fa0dfe3f --- /dev/null +++ b/src/main/kotlin/platform/mcp/at/inspections/AtUnresolvedReferenceInspection.kt @@ -0,0 +1,48 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at.inspections + +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtVisitor +import com.intellij.codeInspection.LocalInspectionTool +import com.intellij.codeInspection.ProblemHighlightType +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiElementVisitor + +class AtUnresolvedReferenceInspection : LocalInspectionTool() { + + override fun getStaticDescription(): String? = "Reports unresolved AT targets." + + override fun buildVisitor( + holder: ProblemsHolder, + isOnTheFly: Boolean + ): PsiElementVisitor = object : AtVisitor() { + override fun visitElement(element: PsiElement) { + super.visitElement(element) + + for (reference in element.references) { + if (reference.resolve() == null) { + holder.registerProblem(reference, ProblemHighlightType.LIKE_UNKNOWN_SYMBOL) + } + } + } + } +} diff --git a/src/main/kotlin/platform/mcp/at/inspections/AtUsageInspection.kt b/src/main/kotlin/platform/mcp/at/inspections/AtUsageInspection.kt new file mode 100644 index 000000000..50145254a --- /dev/null +++ b/src/main/kotlin/platform/mcp/at/inspections/AtUsageInspection.kt @@ -0,0 +1,120 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at.inspections + +import com.demonwav.mcdev.platform.mcp.at.AtFileType +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtEntry +import com.demonwav.mcdev.util.excludeFileTypes +import com.intellij.codeInspection.LocalInspectionTool +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiElementVisitor +import com.intellij.psi.PsiMethod +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.search.searches.OverridingMethodsSearch +import com.intellij.psi.search.searches.ReferencesSearch + +class AtUsageInspection : LocalInspectionTool() { + + override fun getStaticDescription(): String { + return "Reports unused Access Transformer entries" + } + + override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor { + return object : PsiElementVisitor() { + override fun visitElement(element: PsiElement) { + if (element !is AtEntry) { + return + } + + val function = element.function + if (function != null) { + checkElement(element, function) + return + } + + val fieldName = element.fieldName + if (fieldName != null) { + checkElement(element, fieldName) + return + } + + // Only check class names if it is the target of the entry + checkElement(element, element.className) + } + + private fun checkElement(entry: AtEntry, element: PsiElement) { + val referenced = element.reference?.resolve() ?: return + val scope = GlobalSearchScope.projectScope(element.project) + .excludeFileTypes(element.project, AtFileType) + val query = ReferencesSearch.search(referenced, scope, true) + if (query.any()) { + return + } + + if (referenced is PsiMethod) { + // The regular references search doesn't cover overridden methods + val overridingQuery = OverridingMethodsSearch.search(referenced, scope, true) + if (overridingQuery.any()) { + return + } + + // Also ignore if other entries cover super methods + val superMethods = referenced.findSuperMethods() + for (childEntry in entry.containingFile.children) { + if (childEntry !is AtEntry || childEntry == entry) { + continue + } + + val function = childEntry.function ?: continue + val otherResolved = function.reference?.resolve() + if (superMethods.contains(otherResolved)) { + return + } + } + } + + if (referenced is PsiClass) { + // Do not report classes whose members are used in the mod + for (field in referenced.fields) { + if (ReferencesSearch.search(field, scope, true).any()) { + return + } + } + for (method in referenced.methods) { + if (ReferencesSearch.search(method, scope, true).any()) { + return + } + } + for (innerClass in referenced.innerClasses) { + if (ReferencesSearch.search(innerClass, scope, true).any()) { + return + } + } + } + + val fix = RemoveAtEntryFix.forWholeLine(entry, true) + holder.registerProblem(entry, "Access Transformer entry is never used", fix) + } + } + } +} diff --git a/src/main/kotlin/platform/mcp/at/inspections/RemoveAtEntryFix.kt b/src/main/kotlin/platform/mcp/at/inspections/RemoveAtEntryFix.kt new file mode 100644 index 000000000..0221a1495 --- /dev/null +++ b/src/main/kotlin/platform/mcp/at/inspections/RemoveAtEntryFix.kt @@ -0,0 +1,62 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at.inspections + +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtEntry +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtTypes +import com.intellij.codeInspection.LocalQuickFixOnPsiElement +import com.intellij.codeInspection.util.IntentionFamilyName +import com.intellij.codeInspection.util.IntentionName +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.util.elementType +import com.intellij.psi.util.siblings + +class RemoveAtEntryFix(startElement: PsiElement, endElement: PsiElement, val inBatchMode: Boolean) : + LocalQuickFixOnPsiElement(startElement, endElement) { + + override fun getFamilyName(): @IntentionFamilyName String = "Remove entry" + + override fun getText(): @IntentionName String = familyName + + override fun invoke( + project: Project, + file: PsiFile, + startElement: PsiElement, + endElement: PsiElement + ) { + startElement.parent.deleteChildRange(startElement, endElement) + } + + override fun availableInBatchMode(): Boolean = inBatchMode + + companion object { + + fun forWholeLine(entry: AtEntry, inBatchMode: Boolean): RemoveAtEntryFix { + val start = entry.siblings(forward = false, withSelf = false) + .firstOrNull { it.elementType == AtTypes.CRLF }?.nextSibling + val end = entry.siblings(forward = true, withSelf = true) + .firstOrNull { it.elementType == AtTypes.CRLF } + return RemoveAtEntryFix(start ?: entry, end ?: entry, inBatchMode) + } + } +} diff --git a/src/main/kotlin/platform/mcp/at/manipulators.kt b/src/main/kotlin/platform/mcp/at/manipulators.kt new file mode 100644 index 000000000..5422776fd --- /dev/null +++ b/src/main/kotlin/platform/mcp/at/manipulators.kt @@ -0,0 +1,46 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at + +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtClassName +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtFieldName +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtFunction +import com.demonwav.mcdev.platform.mcp.at.psi.AtElement +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import com.intellij.psi.AbstractElementManipulator + +abstract class AtElementManipulator(val factory: (Project, String) -> E) : + AbstractElementManipulator() { + + override fun handleContentChange(element: E, range: TextRange, newContent: String): E? { + val text = element.text + val newText = text.substring(0, range.startOffset) + newContent + text.substring(range.endOffset) + @Suppress("UNCHECKED_CAST") + return element.replace(factory(element.project, newText)) as E + } +} + +class AtClassNameElementManipulator : AtElementManipulator(AtElementFactory::createClassName) + +class AtFieldNameElementManipulator : AtElementManipulator(AtElementFactory::createFieldName) + +class AtFuncNameElementManipulator : AtElementManipulator(AtElementFactory::createFunction) diff --git a/src/main/kotlin/platform/mcp/at/psi/mixins/AtEntryMixin.kt b/src/main/kotlin/platform/mcp/at/psi/mixins/AtEntryMixin.kt index 45599bdb6..12d01ef95 100644 --- a/src/main/kotlin/platform/mcp/at/psi/mixins/AtEntryMixin.kt +++ b/src/main/kotlin/platform/mcp/at/psi/mixins/AtEntryMixin.kt @@ -27,6 +27,8 @@ import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtFieldName import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtFunction import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtKeyword import com.demonwav.mcdev.platform.mcp.at.psi.AtElement +import com.demonwav.mcdev.util.MemberReference +import com.intellij.psi.PsiComment interface AtEntryMixin : AtElement { @@ -35,6 +37,9 @@ interface AtEntryMixin : AtElement { val fieldName: AtFieldName? val function: AtFunction? val keyword: AtKeyword + val comment: PsiComment? + val commentText: String? + val memberReference: MemberReference? fun setEntry(entry: String) fun setKeyword(keyword: AtElementFactory.Keyword) @@ -42,6 +47,7 @@ interface AtEntryMixin : AtElement { fun setFieldName(fieldName: String) fun setFunction(function: String) fun setAsterisk() + fun setComment(text: String?) fun replaceMember(element: AtElement) { // One of these must be true diff --git a/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtClassNameImplMixin.kt b/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtClassNameImplMixin.kt index f9a5a8aa0..f264535ab 100644 --- a/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtClassNameImplMixin.kt +++ b/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtClassNameImplMixin.kt @@ -22,14 +22,16 @@ package com.demonwav.mcdev.platform.mcp.at.psi.mixins.impl import com.demonwav.mcdev.platform.mcp.at.AtElementFactory import com.demonwav.mcdev.platform.mcp.at.psi.mixins.AtClassNameMixin -import com.demonwav.mcdev.util.findQualifiedClass import com.intellij.extapi.psi.ASTWrapperPsiElement import com.intellij.lang.ASTNode +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiReference +import com.intellij.psi.impl.source.resolve.reference.ReferenceProvidersRegistry abstract class AtClassNameImplMixin(node: ASTNode) : ASTWrapperPsiElement(node), AtClassNameMixin { override val classNameValue - get() = findQualifiedClass(project, classNameText) + get() = references.last()?.resolve() as? PsiClass override val classNameText: String get() = classNameElement.text @@ -37,4 +39,12 @@ abstract class AtClassNameImplMixin(node: ASTNode) : ASTWrapperPsiElement(node), override fun setClassName(className: String) { replace(AtElementFactory.createClassName(project, className)) } + + override fun getReference(): PsiReference? { + return references.lastOrNull() + } + + override fun getReferences(): Array { + return ReferenceProvidersRegistry.getReferencesFromProviders(this) + } } diff --git a/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtEntryImplMixin.kt b/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtEntryImplMixin.kt index e4558b6bc..a8a8cfbbc 100644 --- a/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtEntryImplMixin.kt +++ b/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtEntryImplMixin.kt @@ -21,12 +21,26 @@ package com.demonwav.mcdev.platform.mcp.at.psi.mixins.impl import com.demonwav.mcdev.platform.mcp.at.AtElementFactory +import com.demonwav.mcdev.platform.mcp.at.AtMemberReference +import com.demonwav.mcdev.platform.mcp.at.gen.psi.AtEntry import com.demonwav.mcdev.platform.mcp.at.psi.mixins.AtEntryMixin +import com.demonwav.mcdev.util.MemberReference import com.intellij.extapi.psi.ASTWrapperPsiElement import com.intellij.lang.ASTNode +import com.intellij.psi.PsiComment +import com.intellij.psi.util.PsiTreeUtil abstract class AtEntryImplMixin(node: ASTNode) : ASTWrapperPsiElement(node), AtEntryMixin { + override val comment: PsiComment? + get() = PsiTreeUtil.skipWhitespacesForward(this) as? PsiComment + + override val commentText: String? + get() = comment?.text?.substring(1) + + override val memberReference: MemberReference? + get() = (function ?: fieldName ?: asterisk)?.let { AtMemberReference.get(this as AtEntry, it) } + override fun setEntry(entry: String) { replace(AtElementFactory.createEntry(project, entry)) } @@ -53,4 +67,20 @@ abstract class AtEntryImplMixin(node: ASTNode) : ASTWrapperPsiElement(node), AtE val asterisk = AtElementFactory.createAsterisk(project) replaceMember(asterisk) } + + override fun setComment(text: String?) { + if (text == null) { + comment?.delete() + return + } + + val newComment = AtElementFactory.createComment(project, text) + val existingComment = comment + if (existingComment == null) { + parent.addAfter(newComment, this) + return + } + + existingComment.replace(newComment) + } } diff --git a/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtFieldNameImplMixin.kt b/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtFieldNameImplMixin.kt index 982d04d03..84516dc90 100644 --- a/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtFieldNameImplMixin.kt +++ b/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtFieldNameImplMixin.kt @@ -24,6 +24,8 @@ import com.demonwav.mcdev.platform.mcp.at.AtElementFactory import com.demonwav.mcdev.platform.mcp.at.psi.mixins.AtFieldNameMixin import com.intellij.extapi.psi.ASTWrapperPsiElement import com.intellij.lang.ASTNode +import com.intellij.psi.PsiReference +import com.intellij.psi.impl.source.resolve.reference.ReferenceProvidersRegistry abstract class AtFieldNameImplMixin(node: ASTNode) : ASTWrapperPsiElement(node), AtFieldNameMixin { @@ -33,4 +35,12 @@ abstract class AtFieldNameImplMixin(node: ASTNode) : ASTWrapperPsiElement(node), override val fieldNameText: String get() = nameElement.text + + override fun getReference(): PsiReference? { + return references.firstOrNull() + } + + override fun getReferences(): Array { + return ReferenceProvidersRegistry.getReferencesFromProviders(this) + } } diff --git a/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtFunctionImplMixin.kt b/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtFunctionImplMixin.kt index dd8b39df5..5b1d977ed 100644 --- a/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtFunctionImplMixin.kt +++ b/src/main/kotlin/platform/mcp/at/psi/mixins/impl/AtFunctionImplMixin.kt @@ -24,6 +24,8 @@ import com.demonwav.mcdev.platform.mcp.at.AtElementFactory import com.demonwav.mcdev.platform.mcp.at.psi.mixins.AtFunctionMixin import com.intellij.extapi.psi.ASTWrapperPsiElement import com.intellij.lang.ASTNode +import com.intellij.psi.PsiReference +import com.intellij.psi.impl.source.resolve.reference.ReferenceProvidersRegistry abstract class AtFunctionImplMixin(node: ASTNode) : ASTWrapperPsiElement(node), AtFunctionMixin { @@ -42,4 +44,12 @@ abstract class AtFunctionImplMixin(node: ASTNode) : ASTWrapperPsiElement(node), override fun setFunction(function: String) { replace(AtElementFactory.createFunction(project, function)) } + + override fun getReference(): PsiReference? { + return references.firstOrNull() + } + + override fun getReferences(): Array { + return ReferenceProvidersRegistry.getReferencesFromProviders(this) + } } diff --git a/src/main/kotlin/util/ast-utils.kt b/src/main/kotlin/util/ast-utils.kt new file mode 100644 index 000000000..099c70cbf --- /dev/null +++ b/src/main/kotlin/util/ast-utils.kt @@ -0,0 +1,25 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.util + +import com.intellij.lang.ASTNode + +fun ASTNode.children(): Sequence = generateSequence(firstChildNode) { it.treeNext } diff --git a/src/main/kotlin/util/scope-utils.kt b/src/main/kotlin/util/scope-utils.kt new file mode 100644 index 000000000..c4a881a9c --- /dev/null +++ b/src/main/kotlin/util/scope-utils.kt @@ -0,0 +1,33 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.util + +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.project.Project +import com.intellij.psi.search.GlobalSearchScope + +fun GlobalSearchScope.excludeFileTypes(project: Project, vararg fileTypes: FileType): GlobalSearchScope = + this.intersectWith(GlobalSearchScope.everythingScope(project).restrictByFileTypes(*fileTypes).not()) + +fun GlobalSearchScope.restrictByFileTypes(vararg fileTypes: FileType): GlobalSearchScope = + GlobalSearchScope.getScopeRestrictedByFileTypes(this, *fileTypes) + +fun GlobalSearchScope.not(): GlobalSearchScope = GlobalSearchScope.notScope(this) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 0ed01a521..075fea717 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -622,11 +622,21 @@ + + + - + + + + + + implementationClass="com.demonwav.mcdev.platform.mcp.at.inspections.AtUsageInspection"/> + + + + + diff --git a/src/test/kotlin/framework/test-util.kt b/src/test/kotlin/framework/test-util.kt index 8242d9de8..984a427ca 100644 --- a/src/test/kotlin/framework/test-util.kt +++ b/src/test/kotlin/framework/test-util.kt @@ -24,6 +24,7 @@ package com.demonwav.mcdev.framework import com.intellij.ide.highlighter.JavaFileType import com.intellij.lexer.Lexer +import com.intellij.openapi.fileTypes.FileType import com.intellij.openapi.project.Project import com.intellij.openapi.roots.OrderRootType import com.intellij.openapi.roots.libraries.Library @@ -40,6 +41,7 @@ import com.intellij.testFramework.LexerTestCase import com.intellij.testFramework.fixtures.JavaCodeInsightTestFixture import com.intellij.util.ReflectionUtil import org.junit.jupiter.api.Assertions +import org.opentest4j.AssertionFailedError typealias ProjectBuilderFunc = ProjectBuilder.(path: String, code: String, configure: Boolean, allowAst: Boolean) -> VirtualFile @@ -131,3 +133,59 @@ fun testInspectionFix(fixture: JavaCodeInsightTestFixture, basePath: String, fix fixture.launchAction(intention) fixture.checkResult(expected) } + +fun testInspectionFix( + fixture: JavaCodeInsightTestFixture, + fixName: String, + fileType: FileType, + before: String, + after: String +) { + fixture.configureByText(fileType, before) + val intention = fixture.findSingleIntention(fixName) + fixture.launchAction(intention) + fixture.checkResult(after) +} + +fun assertEqualsUnordered(expected: Collection, actual: Collection) { + val expectedSet = expected.toSet() + val actualSet = actual.toSet() + val notFound = expectedSet.minus(actualSet) + val notExpected = actualSet.minus(expectedSet) + + if (notExpected.isNotEmpty() && notFound.isNotEmpty()) { + val message = """| + |Expecting actual: + | $actual + |to contain exactly in any order: + | $expected + |elements not found: + | $notFound + |and elements not expected: + | $notExpected + """.trimMargin() + throw AssertionFailedError(message, expected, actual) + } + if (notFound.isNotEmpty()) { + val message = """| + |Expecting actual: + | $actual + |to contain exactly in any order: + | $expected + |but could not find the following elements: + | $notFound + """.trimMargin() + throw AssertionFailedError(message, expected, actual) + } + if (notExpected.isNotEmpty()) { + val message = """| + |Expecting actual: + | $actual + |to contain exactly in any order: + | $expected + |but the following elements were unexpected: + | $notExpected + """.trimMargin() + throw AssertionFailedError(message, expected, actual) + } +} diff --git a/src/test/kotlin/platform/mcp/at/AtCompletionTest.kt b/src/test/kotlin/platform/mcp/at/AtCompletionTest.kt new file mode 100644 index 000000000..6c6f08d8c --- /dev/null +++ b/src/test/kotlin/platform/mcp/at/AtCompletionTest.kt @@ -0,0 +1,183 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at + +import com.demonwav.mcdev.facet.MinecraftFacet +import com.demonwav.mcdev.framework.BaseMinecraftTest +import com.demonwav.mcdev.framework.assertEqualsUnordered +import com.demonwav.mcdev.platform.PlatformType +import com.demonwav.mcdev.platform.mcp.McpModuleSettings +import com.demonwav.mcdev.platform.mcp.McpModuleType +import com.demonwav.mcdev.platform.mcp.at.AtElementFactory.Keyword +import com.intellij.codeInsight.lookup.Lookup +import com.intellij.openapi.application.runWriteActionAndWait +import org.intellij.lang.annotations.Language +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("Access Transformer Completion Tests") +class AtCompletionTest : BaseMinecraftTest(PlatformType.MCP, PlatformType.NEOFORGE) { + + @BeforeEach + fun setupProject() { + buildProject { + java( + "net/minecraft/Minecraft.java", + """ + package net.minecraft; + public class Minecraft { + private String privString; + private void method() {} + private void overloaded() {} + private void overloaded(String arg) {} + private void copy(TestLibrary from) {} + private void add(int amount) {} + } + """.trimIndent() + ) + java( + "net/minecraft/server/MinecraftServer.java", + """ + package net.minecraft.server; + public class MinecraftServer {} + """.trimIndent() + ) + } + + // Force 1.20.2 because we test the non-SRG member names with NeoForge + MinecraftFacet.getInstance(fixture.module, McpModuleType)!! + .updateSettings(McpModuleSettings.State(minecraftVersion = "1.20.2")) + } + + private fun doCompletionTest( + @Language("Access Transformers") before: String, + @Language("Access Transformers") after: String, + lookupToUse: String? = null + ) { + fixture.configureByText("test_at.cfg", before) + fixture.completeBasic() + if (lookupToUse != null) { + val lookupElement = fixture.lookupElements?.find { it.lookupString == lookupToUse } + assertNotNull(lookupElement, "Could not find lookup element with lookup string '$lookupToUse'") + runWriteActionAndWait { + fixture.lookup.currentItem = lookupElement + } + fixture.type(Lookup.NORMAL_SELECT_CHAR) + } + fixture.checkResult(after) + } + + @Test + @DisplayName("Keyword Lookup Elements In Empty File") + fun keywordLookupElements() { + fixture.configureByText("test_at.cfg", "") + val lookupElements = fixture.completeBasic() + val lookupStrings = lookupElements.map { it.lookupString } + val expectedStrings = Keyword.entries.map { it.text } + assertEqualsUnordered(expectedStrings, lookupStrings) + } + + @Test + @DisplayName("Empty Class Name Lookup Elements") + fun emptyClassNameLookupElements() { + fixture.configureByText("test_at.cfg", "public ") + val lookupElements = fixture.completeBasic() + val lookupStrings = lookupElements.map { it.lookupString } + val expectedStrings = setOf("net.minecraft.Minecraft", "net.minecraft.server.MinecraftServer") + assertEqualsUnordered(expectedStrings, lookupStrings) + } + + @Test + @DisplayName("Class Name Package Lookup Elements") + fun packageLookupElements() { + fixture.configureByText("test_at.cfg", "public net.") + val lookupElements = fixture.completeBasic() + val lookupStrings = lookupElements.map { it.lookupString } + val expectedStrings = setOf("minecraft") + assertEqualsUnordered(expectedStrings, lookupStrings) + } + + @Test + @DisplayName("Class Name Package And Class Lookup Elements") + fun packageAndClassLookupElements() { + fixture.configureByText("test_at.cfg", "public net.minecraft.") + val lookupElements = fixture.completeBasic() + val lookupStrings = lookupElements.map { it.lookupString } + val expectedStrings = setOf("server", "Minecraft") + assertEqualsUnordered(expectedStrings, lookupStrings) + } + + @Test + @DisplayName("Member Lookup Elements") + fun memberLookupElements() { + fixture.configureByText("test_at.cfg", "public net.minecraft.Minecraft ") + val lookupElements = fixture.completeBasic() + val lookupStrings = lookupElements.map { it.lookupString } + val expectedStrings = + setOf("privString", "add(I)V", "copy(L;)V", "method()V", "overloaded()V", "overloaded(Ljava/lang/String;)V") + assertEqualsUnordered(expectedStrings, lookupStrings) + } + + @Test + @DisplayName("Full Class Name Completion") + fun fullClassNameCompletion() { + doCompletionTest( + "public ", + "public net.minecraft.Minecraft", + "net.minecraft.Minecraft" + ) + doCompletionTest( + "public ", + "public net.minecraft.server.MinecraftServer", + "net.minecraft.server.MinecraftServer" + ) + } + + @Test + @DisplayName("Field Name Completion") + fun fieldNameCompletion() { + doCompletionTest( + "public net.minecraft.Minecraft privS", + "public net.minecraft.Minecraft privString" + ) + } + + @Test + @DisplayName("Method Name Completion") + fun methodNameCompletion() { + doCompletionTest( + "public net.minecraft.Minecraft add", + "public net.minecraft.Minecraft add(I)V" + ) + } + + @Test + @DisplayName("Method Name Completion Cleaning End Of Line") + fun methodNameCompletionCleaningEndOfLine() { + doCompletionTest( + "public net.minecraft.Minecraft overloaded(Ljava/some)V invalid; stuff", + "public net.minecraft.Minecraft overloaded(Ljava/lang/String;)V", + "overloaded(Ljava/lang/String;)V" + ) + } +} diff --git a/src/test/kotlin/platform/mcp/at/AtFormatterTest.kt b/src/test/kotlin/platform/mcp/at/AtFormatterTest.kt new file mode 100644 index 000000000..f9d6c5255 --- /dev/null +++ b/src/test/kotlin/platform/mcp/at/AtFormatterTest.kt @@ -0,0 +1,93 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at + +import com.demonwav.mcdev.framework.BaseMinecraftTest +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.psi.codeStyle.CodeStyleManager +import org.intellij.lang.annotations.Language +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("Access Transformer Tests") +class AtFormatterTest : BaseMinecraftTest() { + + private fun doTest( + @Language("Access Transformers") before: String, + @Language("Access Transformers") after: String, + ) { + + fixture.configureByText(AtFileType, before) + WriteCommandAction.runWriteCommandAction(fixture.project) { + CodeStyleManager.getInstance(project).reformat(fixture.file) + } + + fixture.checkResult(after) + } + + @Test + @DisplayName("Entry Comment Spacing") + fun entryCommentSpacing() { + doTest("public Test field# A comment", "public Test field # A comment") + } + + @Test + @DisplayName("Single Group Alignment") + fun singleGroupAlignment() { + doTest( + """ + public Test field # A comment + public+f AnotherTest method()V + """.trimIndent(), + """ + public Test field # A comment + public+f AnotherTest method()V + """.trimIndent() + ) + } + + @Test + @DisplayName("Multiple Groups Alignments") + fun multipleGroupsAlignments() { + doTest( + """ + public net.minecraft.Group1A field + protected net.minecraft.Group1BCD method()V + + public net.minecraft.server.Group2A anotherField + public-f net.minecraft.server.Group2BCD someMethod()V + # A comment in the middle should not join the two groups + protected net.minecraft.world.Group3A anotherField + protected-f net.minecraft.world.Group2BCD someMethod()V + """.trimIndent(), + """ + public net.minecraft.Group1A field + protected net.minecraft.Group1BCD method()V + + public net.minecraft.server.Group2A anotherField + public-f net.minecraft.server.Group2BCD someMethod()V + # A comment in the middle should not join the two groups + protected net.minecraft.world.Group3A anotherField + protected-f net.minecraft.world.Group2BCD someMethod()V + """.trimIndent() + ) + } +} diff --git a/src/test/kotlin/platform/mcp/at/AtReferencesTest.kt b/src/test/kotlin/platform/mcp/at/AtReferencesTest.kt new file mode 100644 index 000000000..5d00519f0 --- /dev/null +++ b/src/test/kotlin/platform/mcp/at/AtReferencesTest.kt @@ -0,0 +1,161 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at + +import com.demonwav.mcdev.facet.MinecraftFacet +import com.demonwav.mcdev.framework.BaseMinecraftTest +import com.demonwav.mcdev.platform.PlatformType +import com.demonwav.mcdev.platform.mcp.McpModuleSettings +import com.demonwav.mcdev.platform.mcp.McpModuleType +import com.intellij.openapi.application.runReadAction +import com.intellij.psi.CommonClassNames +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiField +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiPackage +import org.intellij.lang.annotations.Language +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("Access Transformer References Tests") +class AtReferencesTest : BaseMinecraftTest(PlatformType.MCP, PlatformType.NEOFORGE) { + + @BeforeEach + fun setupProject() { + buildProject { + java( + "com/demonwav/mcdev/mcp/test/TestLibrary.java", + """ + package com.demonwav.mcdev.mcp.test; + public class TestLibrary { + private String privString; + private void method() {} + private void overloaded() {} + private void overloaded(String arg) {} + private void copy(TestLibrary from) {} + private void add(int amount) {} + } + """.trimIndent() + ) + } + + // Force 1.20.2 because we test the non-SRG member names with NeoForge + MinecraftFacet.getInstance(fixture.module, McpModuleType)!! + .updateSettings(McpModuleSettings.State(minecraftVersion = "1.20.2")) + } + + private inline fun testReferenceAtCaret( + @Language("Access Transformers") at: String, + crossinline test: (element: E) -> Unit + ) { + fixture.configureByText("test_at.cfg", at) + runReadAction { + val ref = fixture.getReferenceAtCaretPositionWithAssertion() + val resolved = ref.resolve().also(::assertNotNull)!! + test(assertInstanceOf(E::class.java, resolved)) + } + } + + @Test + @DisplayName("Package Reference") + fun packageReference() { + testReferenceAtCaret("public com.demonwav.mcdev.mcp.test.TestLibrary privString") { pack -> + val expectedPackage = fixture.findPackage("com.demonwav.mcdev.mcp") + assertEquals(expectedPackage, pack) + } + } + + @Test + @DisplayName("Class Reference") + fun classReference() { + testReferenceAtCaret("public com.demonwav.mcdev.mcp.test.TestLibrary privString") { clazz -> + val expectedClass = fixture.findClass("com.demonwav.mcdev.mcp.test.TestLibrary") + assertEquals(expectedClass, clazz) + } + } + + @Test + @DisplayName("Field Reference") + fun fieldReference() { + testReferenceAtCaret("public com.demonwav.mcdev.mcp.test.TestLibrary privString") { field -> + val expectedClass = fixture.findClass("com.demonwav.mcdev.mcp.test.TestLibrary") + val expectedField = expectedClass.findFieldByName("privString", false) + assertEquals(expectedField, field) + } + } + + @Test + @DisplayName("Method Reference") + fun methodReference() { + testReferenceAtCaret("public com.demonwav.mcdev.mcp.test.TestLibrary method()V") { method -> + val expectedClass = fixture.findClass("com.demonwav.mcdev.mcp.test.TestLibrary") + val expectedMethod = expectedClass.findMethodsByName("method", false).single() + assertEquals(expectedMethod, method) + } + } + + @Test + @DisplayName("Method Overload Reference") + fun methodOverloadReference() { + testReferenceAtCaret( + "public com.demonwav.mcdev.mcp.test.TestLibrary overloaded()V" + ) { method -> + val expectedClass = fixture.findClass("com.demonwav.mcdev.mcp.test.TestLibrary") + val expectedMethod = expectedClass.findMethodsByName("overloaded", false).single { !it.hasParameters() } + assertEquals(expectedMethod, method) + } + + testReferenceAtCaret( + "public com.demonwav.mcdev.mcp.test.TestLibrary overloaded(Ljava/lang/String;)V" + ) { method -> + val expectedClass = fixture.findClass("com.demonwav.mcdev.mcp.test.TestLibrary") + val expectedMethod = expectedClass.findMethodsByName("overloaded", false).single { it.hasParameters() } + assertEquals(expectedMethod, method) + } + } + + @Test + @DisplayName("Descriptor Class Type Reference") + fun descriptorClassTypeReference() { + testReferenceAtCaret( + "public com.demonwav.mcdev.mcp.test.TestLibrary copy(Ljava/lang/String;)V" + ) { clazz -> + val expectedClass = fixture.findClass(CommonClassNames.JAVA_LANG_STRING) + assertEquals(expectedClass, clazz) + } + } + + @Test + @DisplayName("Descriptor Primitive Type Reference") + fun descriptorPrimitiveTypeReference() { + testReferenceAtCaret( + "public com.demonwav.mcdev.mcp.test.TestLibrary copy(I)V" + ) { clazz -> + val expectedClass = fixture.findClass(CommonClassNames.JAVA_LANG_INTEGER) + assertEquals(expectedClass, clazz) + } + } +} diff --git a/src/test/kotlin/platform/mcp/at/inspections/AtDuplicateEntryInspectionTest.kt b/src/test/kotlin/platform/mcp/at/inspections/AtDuplicateEntryInspectionTest.kt new file mode 100644 index 000000000..157fa1124 --- /dev/null +++ b/src/test/kotlin/platform/mcp/at/inspections/AtDuplicateEntryInspectionTest.kt @@ -0,0 +1,67 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at.inspections + +import com.demonwav.mcdev.framework.BaseMinecraftTest +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("Access Transformer Duplicate Entry Inspection Tests") +class AtDuplicateEntryInspectionTest : BaseMinecraftTest() { + + @Test + @DisplayName("Duplicate Entries") + fun duplicateEntries() { + buildProject { + at( + "test_at.cfg", + """ + public test.value.UniqueClass + public test.value.DuplicateClass + public test.value.DuplicateClass + + public test.value.UniqueClass * + public test.value.DuplicateClass * + public test.value.DuplicateClass * + + public test.value.UniqueClass *() + public test.value.DuplicateClass *() + public test.value.DuplicateClass *() + + public test.value.UniqueClass field + public test.value.DuplicateClass field + public test.value.DuplicateClass field + + public test.value.UniqueClass method()V + public test.value.DuplicateClass method()V + public test.value.DuplicateClass method()V + + public test.value.UniqueClass method(II)V + public test.value.DuplicateClass method(II)V + public test.value.DuplicateClass method(II)V + """.trimIndent() + ) + } + + fixture.enableInspections(AtDuplicateEntryInspection::class.java) + fixture.checkHighlighting() + } +} diff --git a/src/test/kotlin/platform/mcp/at/inspections/AtInspectionSuppressorTest.kt b/src/test/kotlin/platform/mcp/at/inspections/AtInspectionSuppressorTest.kt new file mode 100644 index 000000000..8b8385fa1 --- /dev/null +++ b/src/test/kotlin/platform/mcp/at/inspections/AtInspectionSuppressorTest.kt @@ -0,0 +1,132 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at.inspections + +import com.demonwav.mcdev.framework.BaseMinecraftTest +import com.demonwav.mcdev.framework.testInspectionFix +import com.demonwav.mcdev.platform.mcp.at.AtFileType +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("Access Transformer Inspection Suppressor Tests") +class AtInspectionSuppressorTest : BaseMinecraftTest() { + + @Test + @DisplayName("Entry-Level Suppress") + fun entryLevelSuppress() { + fixture.configureByText( + "test_at.cfg", + """ + public Unresolved # Suppress:AtUnresolvedReference + public Unresolved + """.trimIndent() + ) + + fixture.enableInspections(AtUnresolvedReferenceInspection::class.java) + fixture.checkHighlighting() + } + + @Test + @DisplayName("Entry-Level Suppress Fix") + fun entryLevelSuppressFix() { + fixture.enableInspections(AtUnresolvedReferenceInspection::class.java) + testInspectionFix( + fixture, + "Suppress AtUnresolvedReference for entry", + AtFileType, + "public Unresolved", + "public Unresolved # Suppress:AtUnresolvedReference" + ) + } + + @Test + @DisplayName("File-Level Suppress") + fun fileLevelSuppress() { + fixture.configureByText( + "test_at.cfg", + """ + # Suppress:AtUnresolvedReference + public Unresolved + public Unresolved + """.trimIndent() + ) + + fixture.enableInspections(AtUnresolvedReferenceInspection::class.java) + fixture.checkHighlighting() + } + + @Test + @DisplayName("File-Level Suppress Fix With No Existing Comments") + fun fileLevelSuppressFixNoComments() { + fixture.enableInspections(AtUnresolvedReferenceInspection::class.java) + testInspectionFix( + fixture, + "Suppress AtUnresolvedReference for file", + AtFileType, + "public Unresolved", + """ + # Suppress:AtUnresolvedReference + public Unresolved + """.trimIndent() + ) + } + + @Test + @DisplayName("File-Level Suppress Fix With Unrelated Comment") + fun fileLevelSuppressFixWithUnrelatedComment() { + fixture.enableInspections(AtUnresolvedReferenceInspection::class.java) + testInspectionFix( + fixture, + "Suppress AtUnresolvedReference for file", + AtFileType, + """ + # This is a header comment + public Unresolved + """.trimIndent(), + """ + # This is a header comment + # Suppress:AtUnresolvedReference + public Unresolved + """.trimIndent() + ) + } + + @Test + @DisplayName("File-Level Suppress Fix With Existing Suppress") + fun fileLevelSuppressFixWithExistingSuppress() { + fixture.enableInspections(AtUnresolvedReferenceInspection::class.java) + testInspectionFix( + fixture, + "Suppress AtUnresolvedReference for file", + AtFileType, + """ + # This is a header comment + # Suppress:AtUsage + public Unresolved + """.trimIndent(), + """ + # This is a header comment + # Suppress:AtUsage,AtUnresolvedReference + public Unresolved + """.trimIndent() + ) + } +} diff --git a/src/test/kotlin/platform/mcp/at/inspections/AtUsageInspectionTest.kt b/src/test/kotlin/platform/mcp/at/inspections/AtUsageInspectionTest.kt new file mode 100644 index 000000000..0d32728ac --- /dev/null +++ b/src/test/kotlin/platform/mcp/at/inspections/AtUsageInspectionTest.kt @@ -0,0 +1,93 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 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.mcp.at.inspections + +import com.demonwav.mcdev.facet.MinecraftFacet +import com.demonwav.mcdev.framework.BaseMinecraftTest +import com.demonwav.mcdev.platform.PlatformType +import com.demonwav.mcdev.platform.mcp.McpModuleSettings +import com.demonwav.mcdev.platform.mcp.McpModuleType +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("Access Transformer Usage Inspection Tests") +class AtUsageInspectionTest : BaseMinecraftTest(PlatformType.MCP, PlatformType.NEOFORGE) { + + @Test + @DisplayName("Usage Inspection") + fun usageInspection() { + buildProject { + java( + "net/minecraft/Used.java", + """ + package net.minecraft; + public class Used { + public int usedField; + public int unusedField; + public void usedMethod() {} + public void unusedMethod() {} + } + """.trimIndent(), + allowAst = true + ) + java( + "net/minecraft/server/Unused.java", + """ + package net.minecraft.server; + public class Unused {} + """.trimIndent(), + allowAst = true + ) + java( + "com/demonwav/mcdev/mcp/test/TestMod.java", + """ + package com.demonwav.mcdev.mcp.test; + public class TestMod { + public TestMod () { + net.minecraft.Used mc = new net.minecraft.Used(); + int value = mc.usedField; + mc.usedMethod(); + } + } + """.trimIndent(), + allowAst = true + ) + at( + "test_at.cfg", + """ + public net.minecraft.Used + public net.minecraft.Used usedField + public net.minecraft.Used unusedField + public net.minecraft.Used usedMethod()V + public net.minecraft.Used unusedMethod()V + public net.minecraft.server.Unused + """.trimIndent() + ) + } + + // Force 1.20.2 because we test the non-SRG member names with NeoForge + MinecraftFacet.getInstance(fixture.module, McpModuleType)!! + .updateSettings(McpModuleSettings.State(minecraftVersion = "1.20.2")) + + fixture.enableInspections(AtUsageInspection::class.java) + fixture.checkHighlighting() + } +}