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
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.ModuleUtilCore
import com.intellij.openapi.project.IntelliJProjectUtil.isIntelliJPlatformProject

View File

@@ -27,6 +27,7 @@ jvm_library(
"//platform/code-style-impl:codeStyle-impl",
"//platform/lang-impl",
"//platform/platform-impl:ide-impl",
"//platform/refactoring",
],
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.lang.impl" />
<orderEntry type="module" module-name="intellij.platform.ide.impl" />
<orderEntry type="module" module-name="intellij.platform.refactoring" />
</component>
</module>

View File

@@ -22,6 +22,7 @@
<langCodeStyleSettingsProvider implementation="com.intellij.lang.properties.codeStyle.PropertiesLanguageCodeStyleSettingsProvider"/>
<lang.ast.factory language="Properties" implementationClass="com.intellij.lang.properties.psi.impl.PropertiesASTFactory"/>
<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"/>
</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"
dynamic="true"/>
<extensionPoint qualifiedName="com.intellij.properties.spellcheckerMnemonicsTokenizer"
interface="com.intellij.lang.properties.spellchecker.tokenizer.MnemonicsTokenizer"
interface="com.intellij.lang.properties.spellchecker.MnemonicsTokenizer"
dynamic="true"/>
</extensionPoints>
@@ -105,8 +105,7 @@
<spellchecker.support language="Properties"
id="propertiesSpellcheckingStrategy"
implementationClass="com.intellij.lang.properties.spellchecker.tokenizer.PropertiesSpellcheckingStrategy"/>
<spellchecker.renamer implementation="com.intellij.lang.properties.spellchecker.handler.PropertySpellcheckingHandler"/>
implementationClass="com.intellij.lang.properties.spellchecker.PropertiesSpellcheckingStrategy"/>
<fileBasedIndex implementation="com.intellij.lang.properties.xml.XmlPropertiesIndex"/>
<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.
package com.intellij.lang.properties.spellchecker.tokenizer;
package com.intellij.lang.properties.spellchecker;
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.tokenizer;
package com.intellij.lang.properties.spellchecker;
import com.intellij.codeInsight.CodeInsightUtilCore;
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.spellchecker.inspections.PlainTextSplitter;
import com.intellij.spellchecker.inspections.PropertiesSplitter;
import com.intellij.spellchecker.inspections.Splitter;
import com.intellij.spellchecker.tokenizer.*;
import org.jetbrains.annotations.NotNull;
@@ -19,7 +20,7 @@ final class PropertiesSpellcheckingStrategy extends SpellcheckingStrategy implem
ExtensionPointName.create("com.intellij.properties.spellcheckerMnemonicsTokenizer");
private final Tokenizer<PropertyValueImpl> myPropertyValueTokenizer = new PropertyValueTokenizer();
private final Tokenizer<PropertyKeyImpl> myPropertyTokenizer = TokenizerBase.create(PropertiesSplitter.getInstance());
private final Tokenizer<PropertyKeyImpl> myPropertyTokenizer = new PropertyKeyTokenizer();
@Override
public @NotNull Tokenizer<?> getTokenizer(PsiElement element) {
@@ -38,6 +39,17 @@ final class PropertiesSpellcheckingStrategy extends SpellcheckingStrategy implem
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> {
@Override
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.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

@@ -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.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
@@ -26,19 +20,13 @@ 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")
@@ -72,43 +60,14 @@ 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
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)
@@ -120,23 +79,10 @@ internal class ChangeTo(typo: String, element: PsiElement, private val range: Te
}
override fun getFileModifierForPreview(target: PsiFile): FileModifier {
return ForPreview(index)
return this
}
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;
}
consumer.consumeToken(element, mySplitter);
consumeToken(element, consumer, mySplitter);
}
public void consumeToken(@NotNull T element, @NotNull TokenConsumer consumer, @NotNull Splitter splitter) {
consumer.consumeToken(element, splitter);
}
}