IJPL-177881 "Fix typo" in a key in .properties file should invoke "Rename" refactoring instead of just fixing the typo

Merge-request: IJ-MR-166998
Merged-by: Ilia Permiashkin <ilia.permiashkin@jetbrains.com>

GitOrigin-RevId: 2dd0def508db09aa5fad66c294de719c6f2f306b
This commit is contained in:
Ilia Permiashkin
2025-06-26 21:52:14 +00:00
committed by intellij-monorepo-bot
parent bb50516f9d
commit 13e74e6862
14 changed files with 73 additions and 104 deletions

View File

@@ -2,7 +2,7 @@
package org.jetbrains.idea.devkit.spellchecker package org.jetbrains.idea.devkit.spellchecker
import com.intellij.lang.properties.psi.impl.PropertyValueImpl import com.intellij.lang.properties.psi.impl.PropertyValueImpl
import com.intellij.lang.properties.spellchecker.tokenizer.MnemonicsTokenizer import com.intellij.lang.properties.spellchecker.MnemonicsTokenizer
import com.intellij.openapi.module.Module import com.intellij.openapi.module.Module
import com.intellij.openapi.module.ModuleUtilCore import com.intellij.openapi.module.ModuleUtilCore
import com.intellij.openapi.project.IntelliJProjectUtil.isIntelliJPlatformProject import com.intellij.openapi.project.IntelliJProjectUtil.isIntelliJPlatformProject

View File

@@ -27,6 +27,7 @@ jvm_library(
"//platform/code-style-impl:codeStyle-impl", "//platform/code-style-impl:codeStyle-impl",
"//platform/lang-impl", "//platform/lang-impl",
"//platform/platform-impl:ide-impl", "//platform/platform-impl:ide-impl",
"//platform/refactoring",
], ],
runtime_deps = [":properties_resources"] runtime_deps = [":properties_resources"]
) )

View File

@@ -23,5 +23,6 @@
<orderEntry type="module" module-name="intellij.platform.codeStyle.impl" /> <orderEntry type="module" module-name="intellij.platform.codeStyle.impl" />
<orderEntry type="module" module-name="intellij.platform.lang.impl" /> <orderEntry type="module" module-name="intellij.platform.lang.impl" />
<orderEntry type="module" module-name="intellij.platform.ide.impl" /> <orderEntry type="module" module-name="intellij.platform.ide.impl" />
<orderEntry type="module" module-name="intellij.platform.refactoring" />
</component> </component>
</module> </module>

View File

@@ -22,6 +22,7 @@
<langCodeStyleSettingsProvider implementation="com.intellij.lang.properties.codeStyle.PropertiesLanguageCodeStyleSettingsProvider"/> <langCodeStyleSettingsProvider implementation="com.intellij.lang.properties.codeStyle.PropertiesLanguageCodeStyleSettingsProvider"/>
<lang.ast.factory language="Properties" implementationClass="com.intellij.lang.properties.psi.impl.PropertiesASTFactory"/> <lang.ast.factory language="Properties" implementationClass="com.intellij.lang.properties.psi.impl.PropertiesASTFactory"/>
<enterHandlerDelegate implementation="com.intellij.lang.properties.EnterInPropertiesFileHandler" id="EnterInPropertiesFileHandler"/> <enterHandlerDelegate implementation="com.intellij.lang.properties.EnterInPropertiesFileHandler" id="EnterInPropertiesFileHandler"/>
<renameInputValidator implementation="com.intellij.lang.properties.rename.PropertyKeyRenameInputValidator"/>
<stripTrailingSpacesFilterFactory implementation="com.intellij.lang.properties.formatting.PropertiesStripTrailingSpacesFilterFactory"/> <stripTrailingSpacesFilterFactory implementation="com.intellij.lang.properties.formatting.PropertiesStripTrailingSpacesFilterFactory"/>
</extensions> </extensions>

View File

@@ -0,0 +1,17 @@
// 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.rename
import com.intellij.lang.properties.psi.impl.PropertyKeyImpl
import com.intellij.patterns.ElementPattern
import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.PsiElement
import com.intellij.refactoring.rename.RenameInputValidator
import com.intellij.util.ProcessingContext
class PropertyKeyRenameInputValidator: RenameInputValidator {
private val myPattern: ElementPattern<out PsiElement?> = PlatformPatterns.psiElement(PropertyKeyImpl::class.java)
override fun getPattern(): ElementPattern<out PsiElement?> = myPattern
override fun isInputValid(newName: String, element: PsiElement, context: ProcessingContext): Boolean = !newName.contains(' ')
}

View File

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

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. // 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.tokenizer; package com.intellij.lang.properties.spellchecker;
import com.intellij.lang.properties.psi.impl.PropertyValueImpl; import com.intellij.lang.properties.psi.impl.PropertyValueImpl;
import com.intellij.spellchecker.tokenizer.TokenConsumer; 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. // 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.tokenizer; package com.intellij.lang.properties.spellchecker;
import com.intellij.codeInsight.CodeInsightUtilCore; import com.intellij.codeInsight.CodeInsightUtilCore;
import com.intellij.lang.properties.psi.Property; import com.intellij.lang.properties.psi.Property;
@@ -10,6 +10,7 @@ import com.intellij.openapi.project.DumbAware;
import com.intellij.psi.PsiElement; import com.intellij.psi.PsiElement;
import com.intellij.spellchecker.inspections.PlainTextSplitter; import com.intellij.spellchecker.inspections.PlainTextSplitter;
import com.intellij.spellchecker.inspections.PropertiesSplitter; import com.intellij.spellchecker.inspections.PropertiesSplitter;
import com.intellij.spellchecker.inspections.Splitter;
import com.intellij.spellchecker.tokenizer.*; import com.intellij.spellchecker.tokenizer.*;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
@@ -19,7 +20,7 @@ final class PropertiesSpellcheckingStrategy extends SpellcheckingStrategy implem
ExtensionPointName.create("com.intellij.properties.spellcheckerMnemonicsTokenizer"); ExtensionPointName.create("com.intellij.properties.spellcheckerMnemonicsTokenizer");
private final Tokenizer<PropertyValueImpl> myPropertyValueTokenizer = new PropertyValueTokenizer(); private final Tokenizer<PropertyValueImpl> myPropertyValueTokenizer = new PropertyValueTokenizer();
private final Tokenizer<PropertyKeyImpl> myPropertyTokenizer = TokenizerBase.create(PropertiesSplitter.getInstance()); private final Tokenizer<PropertyKeyImpl> myPropertyTokenizer = new PropertyKeyTokenizer();
@Override @Override
public @NotNull Tokenizer<?> getTokenizer(PsiElement element) { public @NotNull Tokenizer<?> getTokenizer(PsiElement element) {
@@ -38,6 +39,17 @@ final class PropertiesSpellcheckingStrategy extends SpellcheckingStrategy implem
return super.getTokenizer(element); return super.getTokenizer(element);
} }
private static class PropertyKeyTokenizer extends TokenizerBase<PropertyKeyImpl> {
private PropertyKeyTokenizer() {
super(PropertiesSplitter.getInstance());
}
@Override
public void consumeToken(@NotNull PropertyKeyImpl element, @NotNull TokenConsumer consumer, @NotNull Splitter splitter) {
consumer.consumeToken(element, true, splitter);
}
}
private static class PropertyValueTokenizer extends EscapeSequenceTokenizer<PropertyValueImpl> { private static class PropertyValueTokenizer extends EscapeSequenceTokenizer<PropertyValueImpl> {
@Override @Override
public void tokenize(@NotNull PropertyValueImpl element, @NotNull TokenConsumer consumer) { public void tokenize(@NotNull PropertyValueImpl element, @NotNull TokenConsumer consumer) {

View File

@@ -1,13 +0,0 @@
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

@@ -0,0 +1,28 @@
package com.intellij.lang.properties.rename
import com.intellij.lang.properties.psi.impl.PropertyKeyImpl
import com.intellij.spellchecker.inspections.SpellCheckingInspection
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ProcessingContext
class PropertyRenameToTest : BasePlatformTestCase() {
fun `test suggestions does not contain space symbol`() {
val validator = PropertyKeyRenameInputValidator()
val context = ProcessingContext()
myFixture.configureByText("a.properties", "hellow<caret>orld=value")
val element = myFixture.file.findElementAt(myFixture.editor.caretModel.offset)!! as PropertyKeyImpl
assertTrue(validator.isInputValid("hello-world", element, context))
assertTrue(validator.isInputValid("hello.world", element, context))
assertTrue(validator.isInputValid("hello_world", element, context))
assertFalse(validator.isInputValid("hello world", element, context))
}
fun `test rename action is enabled and visible`() {
myFixture.configureByText("a.properties", "<TYPO descr=\"Typo: In word 'helloworld'\">hellow<caret>orld</TYPO>=value")
myFixture.enableInspections(SpellCheckingInspection())
myFixture.checkHighlighting()
myFixture.getAvailableIntention("Typo: Rename to…") ?: error("RenameTo intention is not available")
}
}

View File

@@ -17,7 +17,6 @@
<extensionPoint name="spellchecker.dictionaryLayersProvider" interface="com.intellij.spellchecker.DictionaryLayersProvider" dynamic="true"/> <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.quickFixFactory" interface="com.intellij.spellchecker.quickfixes.SpellCheckerQuickFixFactory" dynamic="true"/>
<extensionPoint name="spellchecker.lifecycle" interface="com.intellij.spellchecker.grazie.SpellcheckerLifecycle" 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> </extensionPoints>
<extensions defaultExtensionNs="com.intellij"> <extensions defaultExtensionNs="com.intellij">

View File

@@ -1,26 +0,0 @@
// 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,16 +6,10 @@ import com.intellij.codeInsight.intention.HighPriorityAction
import com.intellij.codeInsight.intention.choice.ChoiceTitleIntentionAction import com.intellij.codeInsight.intention.choice.ChoiceTitleIntentionAction
import com.intellij.codeInsight.intention.choice.ChoiceVariantIntentionAction import com.intellij.codeInsight.intention.choice.ChoiceVariantIntentionAction
import com.intellij.codeInsight.intention.choice.DefaultIntentionActionWithChoice import com.intellij.codeInsight.intention.choice.DefaultIntentionActionWithChoice
import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo
import com.intellij.codeInsight.intention.preview.IntentionPreviewUtils 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.Document
import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.impl.DocumentMarkupModel 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.DumbAware
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
import com.intellij.openapi.util.NlsSafe import com.intellij.openapi.util.NlsSafe
@@ -26,19 +20,13 @@ import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile import com.intellij.psi.PsiFile
import com.intellij.psi.SmartPointerManager import com.intellij.psi.SmartPointerManager
import com.intellij.psi.util.startOffset 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.spellchecker.util.SpellCheckerBundle
import com.intellij.util.concurrency.ThreadingAssertions 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) { 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) private val pointer = SmartPointerManager.getInstance(element.project).createSmartPsiElementPointer(element, element.containingFile)
companion object { companion object {
private val EP_NAME = ExtensionPointName.create<SpellcheckingElementHandler>("com.intellij.spellchecker.renamer")
@JvmStatic @JvmStatic
val fixName: String by lazy { val fixName: String by lazy {
SpellCheckerBundle.message("change.to.title") SpellCheckerBundle.message("change.to.title")
@@ -72,43 +60,14 @@ internal class ChangeTo(typo: String, element: PsiElement, private val range: Te
override fun applyFix(project: Project, psiFile: PsiFile, editor: Editor?) { override fun applyFix(project: Project, psiFile: PsiFile, editor: Editor?) {
val suggestion = suggestion ?: return val suggestion = suggestion ?: return
val document = psiFile.viewProvider.document val document = psiFile.viewProvider.document
val myRange = getRange(document) ?: return val myRange = getRange(document) ?: return
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) removeHighlightersWithExactRange(document, project, myRange)
document.replaceString(myRange.startOffset, myRange.endOffset, suggestion) 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? { private fun getRange(document: Document): TextRange? {
val element = pointer.element ?: return null val element = pointer.element ?: return null
val range = range.shiftRight(element.startOffset) val range = range.shiftRight(element.startOffset)
@@ -120,23 +79,10 @@ internal class ChangeTo(typo: String, element: PsiElement, private val range: Te
} }
override fun getFileModifierForPreview(target: PsiFile): FileModifier { override fun getFileModifierForPreview(target: PsiFile): FileModifier {
return ForPreview(index) return this
} }
override fun startInWriteAction(): Boolean = true 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)
}
}
} }

View File

@@ -30,6 +30,10 @@ public class TokenizerBase<T extends PsiElement> extends Tokenizer<T> {
return; return;
} }
consumer.consumeToken(element, mySplitter); consumeToken(element, consumer, mySplitter);
}
public void consumeToken(@NotNull T element, @NotNull TokenConsumer consumer, @NotNull Splitter splitter) {
consumer.consumeToken(element, splitter);
} }
} }