[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 <ilia.permiashkin@jetbrains.com>

GitOrigin-RevId: 9aa1e7d8a4b296936e1994e86268ffdfa64d28ef
This commit is contained in:
Ilia Permiashkin
2025-06-14 16:24:58 +00:00
committed by intellij-monorepo-bot
parent 36831d7c33
commit bfc4a9be16
8 changed files with 109 additions and 15 deletions

View File

@@ -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

View File

@@ -24,7 +24,7 @@
interface="com.intellij.lang.properties.codeInspection.unused.ExtendedUseScopeProvider"
dynamic="true"/>
<extensionPoint qualifiedName="com.intellij.properties.spellcheckerMnemonicsTokenizer"
interface="com.intellij.lang.properties.spellchecker.MnemonicsTokenizer"
interface="com.intellij.lang.properties.spellchecker.tokenizer.MnemonicsTokenizer"
dynamic="true"/>
</extensionPoints>
@@ -105,7 +105,8 @@
<spellchecker.support language="Properties"
id="propertiesSpellcheckingStrategy"
implementationClass="com.intellij.lang.properties.spellchecker.PropertiesSpellcheckingStrategy"/>
implementationClass="com.intellij.lang.properties.spellchecker.tokenizer.PropertiesSpellcheckingStrategy"/>
<spellchecker.renamer implementation="com.intellij.lang.properties.spellchecker.handler.PropertySpellcheckingHandler"/>
<fileBasedIndex implementation="com.intellij.lang.properties.xml.XmlPropertiesIndex"/>
<standardResource url="http://java.sun.com/dtd/properties.dtd" path="schemas/properties.dtd"/>

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -16,6 +16,7 @@
<extensionPoint name="spellchecker.dictionaryLayersProvider" interface="com.intellij.spellchecker.DictionaryLayersProvider" dynamic="true"/>
<extensionPoint name="spellchecker.quickFixFactory" interface="com.intellij.spellchecker.quickfixes.SpellCheckerQuickFixFactory" dynamic="true"/>
<extensionPoint name="spellchecker.lifecycle" interface="com.intellij.spellchecker.grazie.SpellcheckerLifecycle" dynamic="true"/>
<extensionPoint name="spellchecker.renamer" interface="com.intellij.spellchecker.handler.SpellcheckingElementHandler" dynamic="true"/>
</extensionPoints>
<extensions defaultExtensionNs="com.intellij">

View File

@@ -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?
}

View File

@@ -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<SpellcheckingElementHandler>("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)
}
}
}