From bfc4a9be164cdce75b1dca26bc622ade2c52d7e6 Mon Sep 17 00:00:00 2001 From: Ilia Permiashkin Date: Sat, 14 Jun 2025 16:24:58 +0000 Subject: [PATCH] [spellchecker] IJPL-177881 "Fix typo" in a key in .properties file should invoke "Rename" refactoring instead of just fixing the typo Merge-request: IJ-MR-165210 Merged-by: Ilia Permiashkin GitOrigin-RevId: 9aa1e7d8a4b296936e1994e86268ffdfa64d28ef --- .../spellchecker/DevKitMnemonicsTokenizer.kt | 2 +- .../resources/intellij.properties.backend.xml | 7 +- .../handler/PropertySpellcheckingHandler.kt | 13 ++++ .../{ => tokenizer}/MnemonicsTokenizer.java | 2 +- .../PropertiesSpellcheckingStrategy.java | 2 +- .../resources/intellij.spellchecker.xml | 1 + .../handler/SpellcheckingElementHandler.kt | 26 +++++++ .../spellchecker/quickfixes/ChangeTo.kt | 71 ++++++++++++++++--- 8 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 plugins/properties/src/com/intellij/lang/properties/spellchecker/handler/PropertySpellcheckingHandler.kt rename plugins/properties/src/com/intellij/lang/properties/spellchecker/{ => tokenizer}/MnemonicsTokenizer.java (88%) rename plugins/properties/src/com/intellij/lang/properties/spellchecker/{ => tokenizer}/PropertiesSpellcheckingStrategy.java (97%) create mode 100644 spellchecker/src/com/intellij/spellchecker/handler/SpellcheckingElementHandler.kt diff --git a/plugins/devkit/devkit-core/src/spellchecker/DevKitMnemonicsTokenizer.kt b/plugins/devkit/devkit-core/src/spellchecker/DevKitMnemonicsTokenizer.kt index 3c43ef09ee3d..f0a51640e1f0 100644 --- a/plugins/devkit/devkit-core/src/spellchecker/DevKitMnemonicsTokenizer.kt +++ b/plugins/devkit/devkit-core/src/spellchecker/DevKitMnemonicsTokenizer.kt @@ -2,7 +2,7 @@ package org.jetbrains.idea.devkit.spellchecker import com.intellij.lang.properties.psi.impl.PropertyValueImpl -import com.intellij.lang.properties.spellchecker.MnemonicsTokenizer +import com.intellij.lang.properties.spellchecker.tokenizer.MnemonicsTokenizer import com.intellij.openapi.module.Module import com.intellij.openapi.module.ModuleUtilCore import com.intellij.openapi.project.IntelliJProjectUtil.isIntelliJPlatformProject diff --git a/plugins/properties/resources/intellij.properties.backend.xml b/plugins/properties/resources/intellij.properties.backend.xml index e5a4834992db..f250db87e988 100644 --- a/plugins/properties/resources/intellij.properties.backend.xml +++ b/plugins/properties/resources/intellij.properties.backend.xml @@ -24,7 +24,7 @@ interface="com.intellij.lang.properties.codeInspection.unused.ExtendedUseScopeProvider" dynamic="true"/> @@ -105,7 +105,8 @@ + implementationClass="com.intellij.lang.properties.spellchecker.tokenizer.PropertiesSpellcheckingStrategy"/> + @@ -175,4 +176,4 @@ - \ No newline at end of file + diff --git a/plugins/properties/src/com/intellij/lang/properties/spellchecker/handler/PropertySpellcheckingHandler.kt b/plugins/properties/src/com/intellij/lang/properties/spellchecker/handler/PropertySpellcheckingHandler.kt new file mode 100644 index 000000000000..fcccfc358522 --- /dev/null +++ b/plugins/properties/src/com/intellij/lang/properties/spellchecker/handler/PropertySpellcheckingHandler.kt @@ -0,0 +1,13 @@ +package com.intellij.lang.properties.spellchecker.handler + +import com.intellij.lang.properties.psi.impl.PropertyKeyImpl +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiNamedElement +import com.intellij.spellchecker.handler.SpellcheckingElementHandler + +class PropertySpellcheckingHandler : SpellcheckingElementHandler { + + override fun isEligibleForRenaming(psiElement: PsiElement): Boolean = psiElement is PropertyKeyImpl + + override fun getNamedElement(psiElement: PsiElement): PsiNamedElement? = psiElement.parent as PsiNamedElement +} \ No newline at end of file diff --git a/plugins/properties/src/com/intellij/lang/properties/spellchecker/MnemonicsTokenizer.java b/plugins/properties/src/com/intellij/lang/properties/spellchecker/tokenizer/MnemonicsTokenizer.java similarity index 88% rename from plugins/properties/src/com/intellij/lang/properties/spellchecker/MnemonicsTokenizer.java rename to plugins/properties/src/com/intellij/lang/properties/spellchecker/tokenizer/MnemonicsTokenizer.java index da8eb39749fc..860aaedda235 100644 --- a/plugins/properties/src/com/intellij/lang/properties/spellchecker/MnemonicsTokenizer.java +++ b/plugins/properties/src/com/intellij/lang/properties/spellchecker/tokenizer/MnemonicsTokenizer.java @@ -1,5 +1,5 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -package com.intellij.lang.properties.spellchecker; +package com.intellij.lang.properties.spellchecker.tokenizer; import com.intellij.lang.properties.psi.impl.PropertyValueImpl; import com.intellij.spellchecker.tokenizer.TokenConsumer; diff --git a/plugins/properties/src/com/intellij/lang/properties/spellchecker/PropertiesSpellcheckingStrategy.java b/plugins/properties/src/com/intellij/lang/properties/spellchecker/tokenizer/PropertiesSpellcheckingStrategy.java similarity index 97% rename from plugins/properties/src/com/intellij/lang/properties/spellchecker/PropertiesSpellcheckingStrategy.java rename to plugins/properties/src/com/intellij/lang/properties/spellchecker/tokenizer/PropertiesSpellcheckingStrategy.java index c11c50861eac..546842abca6a 100644 --- a/plugins/properties/src/com/intellij/lang/properties/spellchecker/PropertiesSpellcheckingStrategy.java +++ b/plugins/properties/src/com/intellij/lang/properties/spellchecker/tokenizer/PropertiesSpellcheckingStrategy.java @@ -1,5 +1,5 @@ // Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -package com.intellij.lang.properties.spellchecker; +package com.intellij.lang.properties.spellchecker.tokenizer; import com.intellij.codeInsight.CodeInsightUtilCore; import com.intellij.lang.properties.psi.Property; diff --git a/spellchecker/resources/intellij.spellchecker.xml b/spellchecker/resources/intellij.spellchecker.xml index 2eff79281354..2f44c22b0da6 100644 --- a/spellchecker/resources/intellij.spellchecker.xml +++ b/spellchecker/resources/intellij.spellchecker.xml @@ -16,6 +16,7 @@ + diff --git a/spellchecker/src/com/intellij/spellchecker/handler/SpellcheckingElementHandler.kt b/spellchecker/src/com/intellij/spellchecker/handler/SpellcheckingElementHandler.kt new file mode 100644 index 000000000000..41b15158b4eb --- /dev/null +++ b/spellchecker/src/com/intellij/spellchecker/handler/SpellcheckingElementHandler.kt @@ -0,0 +1,26 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.spellchecker.handler + +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiNamedElement +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +interface SpellcheckingElementHandler { + + /** + * Determines whether the given `psiElement` is eligible for renaming. + * + * @param psiElement the PSI element to be checked for renaming eligibility + * @return `true` if the provided element can be renamed, otherwise `false` + */ + fun isEligibleForRenaming(psiElement: PsiElement): Boolean + + /** + * Returns the [PsiNamedElement] associated with the given `psiElement`, if any. + * + * @param psiElement the PSI element to get the named element for + * @return the named element associated with the given `psiElement`, or `null` if no named element is found + */ + fun getNamedElement(psiElement: PsiElement): PsiNamedElement? +} \ No newline at end of file diff --git a/spellchecker/src/com/intellij/spellchecker/quickfixes/ChangeTo.kt b/spellchecker/src/com/intellij/spellchecker/quickfixes/ChangeTo.kt index 17f45e530f87..a58747bd28d1 100644 --- a/spellchecker/src/com/intellij/spellchecker/quickfixes/ChangeTo.kt +++ b/spellchecker/src/com/intellij/spellchecker/quickfixes/ChangeTo.kt @@ -6,10 +6,16 @@ import com.intellij.codeInsight.intention.HighPriorityAction import com.intellij.codeInsight.intention.choice.ChoiceTitleIntentionAction import com.intellij.codeInsight.intention.choice.ChoiceVariantIntentionAction import com.intellij.codeInsight.intention.choice.DefaultIntentionActionWithChoice +import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo import com.intellij.codeInsight.intention.preview.IntentionPreviewUtils +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.writeIntentReadAction import com.intellij.openapi.editor.Document import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.impl.DocumentMarkupModel +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.progress.currentThreadCoroutineScope +import com.intellij.openapi.progress.withCurrentThreadCoroutineScopeBlocking import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.util.NlsSafe @@ -20,13 +26,19 @@ import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.psi.SmartPointerManager import com.intellij.psi.util.startOffset +import com.intellij.refactoring.rename.PsiElementRenameHandler +import com.intellij.spellchecker.handler.SpellcheckingElementHandler import com.intellij.spellchecker.util.SpellCheckerBundle import com.intellij.util.concurrency.ThreadingAssertions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch internal class ChangeTo(typo: String, element: PsiElement, private val range: TextRange) : DefaultIntentionActionWithChoice, LazySuggestions(typo) { private val pointer = SmartPointerManager.getInstance(element.project).createSmartPsiElementPointer(element, element.containingFile) companion object { + private val EP_NAME = ExtensionPointName.create("com.intellij.spellchecker.renamer") + @JvmStatic val fixName: String by lazy { SpellCheckerBundle.message("change.to.title") @@ -37,9 +49,9 @@ internal class ChangeTo(typo: String, element: PsiElement, private val range: Te override fun getTitle(): ChoiceTitleIntentionAction = ChangeToTitleAction - private inner class ChangeToVariantAction( - override val index: Int - ) : ChoiceVariantIntentionAction(), HighPriorityAction, DumbAware { + private open inner class ChangeToVariantAction( + override val index: Int, + ) : ChoiceVariantIntentionAction(), HighPriorityAction { @NlsSafe private var suggestion: String? = null @@ -60,15 +72,43 @@ internal class ChangeTo(typo: String, element: PsiElement, private val range: Te override fun applyFix(project: Project, psiFile: PsiFile, editor: Editor?) { val suggestion = suggestion ?: return - val document = psiFile.viewProvider.document val myRange = getRange(document) ?: return - removeHighlightersWithExactRange(document, project, myRange) + pointer.element?.let { element -> + getElementHandler(element)?.let { handler -> + val typo = document.text.substring(range.startOffset, range.endOffset) + val value = element.text.replace(typo, suggestion) + handler.getNamedElement(element)?.let { namedElement -> + runOnEdt { + if (namedElement.isValid) { + PsiElementRenameHandler.rename(namedElement, project, namedElement, editor, value) + } + } + return@applyFix + } + } + } + + removeHighlightersWithExactRange(document, project, myRange) document.replaceString(myRange.startOffset, myRange.endOffset, suggestion) } - + + private fun getElementHandler(element: PsiElement): SpellcheckingElementHandler? { + return EP_NAME.extensionList.asSequence().filter { it.isEligibleForRenaming(element) }.firstOrNull() + } + + fun runOnEdt(runnable: Runnable) { + withCurrentThreadCoroutineScopeBlocking { + currentThreadCoroutineScope().launch(Dispatchers.EDT) { + writeIntentReadAction { + runnable.run() + } + } + } + } + private fun getRange(document: Document): TextRange? { val element = pointer.element ?: return null val range = range.shiftRight(element.startOffset) @@ -80,10 +120,23 @@ internal class ChangeTo(typo: String, element: PsiElement, private val range: Te } override fun getFileModifierForPreview(target: PsiFile): FileModifier { - return this + return ForPreview(index) } override fun startInWriteAction(): Boolean = true + + private inner class ForPreview( + index: Int, + ) : ChangeToVariantAction(index = index), IntentionPreviewInfo { + override fun applyFix(project: Project, psiFile: PsiFile, editor: Editor?) { + val suggestion = suggestion ?: return + val document = psiFile.viewProvider.document + val myRange = getRange(document) ?: return + + removeHighlightersWithExactRange(document, project, myRange) + document.replaceString(myRange.startOffset, myRange.endOffset, suggestion) + } + } } @@ -107,8 +160,8 @@ internal class ChangeTo(typo: String, element: PsiElement, private val range: Te val model = DocumentMarkupModel.forDocument(document, project, false) ?: return for (highlighter in model.allHighlighters) { - if (TextRange.areSegmentsEqual(range, highlighter!!)) { - model.removeHighlighter(highlighter!!) + if (TextRange.areSegmentsEqual(range, highlighter)) { + model.removeHighlighter(highlighter) } } }