From 1dc57b71676d65b52355b7a87987f07f0fee0b32 Mon Sep 17 00:00:00 2001 From: Ilia Permiashkin Date: Thu, 9 Oct 2025 08:39:29 +0000 Subject: [PATCH] [grazie] IJPL-203135 Perform text spellchecking in the cloud if available Co-authored-by: Peter Gromov Merge-request: IJ-MR-177886 Merged-by: Ilia Permiashkin GitOrigin-RevId: 92e5ca3aa506b7650069ec0aa08b9259242c9a01 --- plugins/grazie/resources/META-INF/plugin.xml | 1 + .../intellij/grazie/cloud/DependencyParser.kt | 2 +- .../grazie/cloud/GrazieCloudConnector.kt | 9 +- .../com/intellij/grazie/mlec/MlecChecker.kt | 56 +-------- .../intellij/grazie/rule/SentenceBatcher.kt | 36 +++++- .../GrazieSpellCheckingInspection.java | 21 +++- .../GrazieTextLevelSpellCheckingExtension.kt | 109 +++++++++++++++--- .../com/intellij/grazie/utils/GrazieUtils.kt | 28 ++++- .../spellchecker/quickfixes/ChangeTo.kt | 3 +- .../quickfixes/LazySuggestions.java | 8 +- .../quickfixes/SpellCheckerQuickFixFactory.kt | 10 +- .../tokenizer/SpellcheckingStrategy.java | 22 ++-- 12 files changed, 217 insertions(+), 88 deletions(-) diff --git a/plugins/grazie/resources/META-INF/plugin.xml b/plugins/grazie/resources/META-INF/plugin.xml index 31e105abef90..03202d97ef58 100644 --- a/plugins/grazie/resources/META-INF/plugin.xml +++ b/plugins/grazie/resources/META-INF/plugin.xml @@ -111,6 +111,7 @@ + diff --git a/plugins/grazie/src/main/kotlin/com/intellij/grazie/cloud/DependencyParser.kt b/plugins/grazie/src/main/kotlin/com/intellij/grazie/cloud/DependencyParser.kt index d7519e65eff8..ba80a30f4273 100644 --- a/plugins/grazie/src/main/kotlin/com/intellij/grazie/cloud/DependencyParser.kt +++ b/plugins/grazie/src/main/kotlin/com/intellij/grazie/cloud/DependencyParser.kt @@ -144,7 +144,7 @@ object DependencyParser { private class Batcher(language: Language): SentenceBatcher(language, TreeSupport.CLOUD_BATCH_SIZE, quoteMarkup = true) { override suspend fun parse(sentences: List, project: Project): Map { - if (GrazieCloudConnector.EP_NAME.extensionList.any { it.isAfterRecentGecError() }) { + if (GrazieCloudConnector.isAfterRecentGecError()) { return emptyMap() } val support = obtainSupport(language) ?: return emptyMap() diff --git a/plugins/grazie/src/main/kotlin/com/intellij/grazie/cloud/GrazieCloudConnector.kt b/plugins/grazie/src/main/kotlin/com/intellij/grazie/cloud/GrazieCloudConnector.kt index 80ec987a3b8e..01038fe9ec1e 100644 --- a/plugins/grazie/src/main/kotlin/com/intellij/grazie/cloud/GrazieCloudConnector.kt +++ b/plugins/grazie/src/main/kotlin/com/intellij/grazie/cloud/GrazieCloudConnector.kt @@ -46,7 +46,12 @@ interface GrazieCloudConnector { /** * Returns machine learning errors for the given [sentences] in the given [language]. */ - suspend fun mlec(sentences: List, lang: Language, project: Project): List? + suspend fun mlec(sentences: List, language: Language, project: Project): List? + + /** + * Returns spelling errors for the given [text] in the given [language]. + */ + suspend fun spell(sentences: List, language: Language, project: Project): List? /** * Subscribe to authorization state change events. @@ -57,5 +62,7 @@ interface GrazieCloudConnector { val EP_NAME = ExtensionPointName("com.intellij.grazie.cloudConnector") fun seemsCloudConnected(): Boolean = EP_NAME.extensionList.any { it.seemsCloudConnected() } + + fun isAfterRecentGecError(): Boolean = EP_NAME.extensionList.any { it.isAfterRecentGecError() } } } \ No newline at end of file diff --git a/plugins/grazie/src/main/kotlin/com/intellij/grazie/mlec/MlecChecker.kt b/plugins/grazie/src/main/kotlin/com/intellij/grazie/mlec/MlecChecker.kt index 7bc49de08f51..9e83d2085ef3 100644 --- a/plugins/grazie/src/main/kotlin/com/intellij/grazie/mlec/MlecChecker.kt +++ b/plugins/grazie/src/main/kotlin/com/intellij/grazie/mlec/MlecChecker.kt @@ -1,6 +1,5 @@ package com.intellij.grazie.mlec -import ai.grazie.gec.model.problem.Problem import ai.grazie.gec.model.problem.SentenceWithProblems import ai.grazie.nlp.langs.Language import ai.grazie.nlp.langs.locale @@ -10,23 +9,15 @@ import com.intellij.codeInspection.util.InspectionMessage import com.intellij.grazie.GrazieConfig import com.intellij.grazie.cloud.GrazieCloudConnector import com.intellij.grazie.rule.SentenceBatcher -import com.intellij.grazie.rule.SentenceTokenizer.Sentence -import com.intellij.grazie.rule.SentenceTokenizer.tokenize import com.intellij.grazie.text.* import com.intellij.grazie.utils.* import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service -import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.util.TextRange import com.intellij.openapi.util.text.StringUtil -import com.intellij.psi.FileViewProvider -import org.slf4j.LoggerFactory import java.util.* -import kotlin.coroutines.cancellation.CancellationException - -private val logger = LoggerFactory.getLogger(MlecChecker::class.java) class MlecChecker : ExternalTextChecker() { private val incompleteSentenceMessages = setOf( @@ -46,7 +37,7 @@ class MlecChecker : ExternalTextChecker() { val rules = Constants.mlecRules[context.language] ?: return emptyList() if (rules.none { it.isCurrentlyEnabled(context.text) }) return emptyList() - val typos = getTypos(context.language, context.text, context.stripPrefix.length).takeIf { it.isNotEmpty() } ?: return emptyList() + val typos = getProblems(context, MlecServerBatcherHolder::class.java)?.takeIf { it.isNotEmpty() } ?: return emptyList() return typos .mapNotNull { typo -> @@ -92,47 +83,6 @@ class MlecChecker : ExternalTextChecker() { } } - private suspend fun getTypos(language: Language, text: TextContent, stripPrefixLength: Int): List { - val subText = text.subText(TextRange(stripPrefixLength, text.length)) ?: return emptyList() - val sentences = tokenize(subText) - val parsed: Map? = runMlec(sentences, language, text.containingFile.viewProvider) - if (parsed.isNullOrEmpty()) return emptyList() - - val result = ArrayList() - for (sentence in sentences) { - val corrections = parsed[sentence.swe()]?.problems ?: continue - val start = sentence.start + stripPrefixLength - if (!text.hasUnknownFragmentsIn(TextRange.from(start, sentence.text.trimEnd().length))) { - corrections.forEach { - result.add(it.withOffset(start)) - } - } - } - return result - } - - private suspend fun runMlec( - sentences: List, language: Language, - vp: FileViewProvider, - ): Map? { - val queries = sentences.map { it.swe() } - if (!GrazieCloudConnector.seemsCloudConnected()) { - return LinkedHashMap() - } - - return try { - val parser = service().get(language)?.forFile(vp) - parser?.parseAsync(queries) - } - catch (e: CancellationException) { - throw e - } - catch (e: RuntimeException) { - logger.warn("Got exception from MLEC", e) - LinkedHashMap() - } - } - @Suppress("NonAsciiCharacters") object Constants { const val MLEC_RULE_PREFIX = "Grazie.MLEC" @@ -189,12 +139,12 @@ class MlecChecker : ExternalTextChecker() { } @Service - private class ServerBatcherHolder : LanguageHolder>() { + private class MlecServerBatcherHolder : LanguageHolder>() { private class ServerBatcher( language: Language, ) : SentenceBatcher(language, 32), Disposable { override suspend fun parse(sentences: List, project: Project): Map { - if (GrazieCloudConnector.EP_NAME.extensionList.any { it.isAfterRecentGecError() }) { + if (GrazieCloudConnector.isAfterRecentGecError()) { return emptyMap() } return GrazieCloudConnector.EP_NAME.extensionList diff --git a/plugins/grazie/src/main/kotlin/com/intellij/grazie/rule/SentenceBatcher.kt b/plugins/grazie/src/main/kotlin/com/intellij/grazie/rule/SentenceBatcher.kt index 21efed6e496d..6885acb1b5d2 100644 --- a/plugins/grazie/src/main/kotlin/com/intellij/grazie/rule/SentenceBatcher.kt +++ b/plugins/grazie/src/main/kotlin/com/intellij/grazie/rule/SentenceBatcher.kt @@ -1,14 +1,19 @@ package com.intellij.grazie.rule +import ai.grazie.gec.model.problem.SentenceWithProblems import ai.grazie.nlp.langs.Language import ai.grazie.rules.util.BatchParser import ai.grazie.text.exclusions.SentenceWithExclusions +import com.intellij.grazie.cloud.GrazieCloudConnector +import com.intellij.grazie.mlec.LanguageHolder +import com.intellij.grazie.rule.SentenceTokenizer.Sentence import com.intellij.grazie.text.TextContent import com.intellij.grazie.text.TextExtractor.findAllTextContents import com.intellij.grazie.utils.HighlightingUtil import com.intellij.grazie.utils.NaturalTextDetector import com.intellij.grazie.utils.getLanguageIfAvailable import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.runBlockingCancellable @@ -25,6 +30,7 @@ import com.intellij.util.containers.ContainerUtil import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.cancellation.CancellationException abstract class SentenceBatcher(val language: Language, private val batchSize: Int, private val quoteMarkup: Boolean = false) : Disposable { @Volatile @@ -33,7 +39,7 @@ abstract class SentenceBatcher(val language: Language, private val batchSize: private val executorScope = CoroutineScope(SupervisorJob() + CoroutineName(name = "SentenceBatcherScope-${this::class.java.name}")) private val taskMutex = Mutex() - protected abstract suspend fun parse(sentences: List, project: Project): Map + protected abstract suspend fun parse(sentences: List, project: Project): Map? override fun dispose() { executorScope.cancel() @@ -131,7 +137,8 @@ abstract class SentenceBatcher(val language: Language, private val batchSize: } private suspend fun parseAndCache(batch: LinkedHashSet): Map { - val parsed: Map = parse(ArrayList(batch), project) + val parsed = parse(ArrayList(batch), project) + if (parsed == null) return LinkedHashMap() for (entry in parsed.entries) { if (entry.value != null) { globalCache[entry.key] = entry.value @@ -196,6 +203,31 @@ abstract class SentenceBatcher(val language: Language, private val batchSize: fun findInstalledLTLanguage(language: Language): org.languagetool.Language? { return HighlightingUtil.findInstalledLang(language)?.jLanguage } + + @JvmStatic + suspend fun >> runWithSentenceBatcher( + sentences: List, + language: Language, + vp: FileViewProvider, + parserClass: Class + ): Map? { + val queries = sentences.map { it.swe() } + if (!GrazieCloudConnector.seemsCloudConnected() || GrazieCloudConnector.isAfterRecentGecError()) { + return LinkedHashMap() + } + + return try { + val parser = ApplicationManager.getApplication().getService(parserClass).get(language)?.forFile(vp) + parser?.parseAsync(queries) + } + catch (e: CancellationException) { + throw e + } + catch (e: RuntimeException) { + LOG.warn("Got exception from $parserClass", e) + LinkedHashMap() + } + } } } \ No newline at end of file diff --git a/plugins/grazie/src/main/kotlin/com/intellij/grazie/spellcheck/GrazieSpellCheckingInspection.java b/plugins/grazie/src/main/kotlin/com/intellij/grazie/spellcheck/GrazieSpellCheckingInspection.java index e2a9af8ab675..bbf77fff8c58 100644 --- a/plugins/grazie/src/main/kotlin/com/intellij/grazie/spellcheck/GrazieSpellCheckingInspection.java +++ b/plugins/grazie/src/main/kotlin/com/intellij/grazie/spellcheck/GrazieSpellCheckingInspection.java @@ -149,14 +149,14 @@ public final class GrazieSpellCheckingInspection extends SpellCheckingInspection } private static void addRegularDescriptor(@NotNull PsiElement element, @NotNull TextRange textRange, @NotNull ProblemsHolder holder, - boolean useRename, String wordWithTypo) { + boolean useRename, String wordWithTypo, Set suggestions) { SpellcheckingStrategy strategy = getSpellcheckingStrategy(element); LocalQuickFix[] fixes = strategy != null - ? strategy.getRegularFixes(element, textRange, useRename, wordWithTypo) - : SpellcheckingStrategy.getDefaultRegularFixes(useRename, wordWithTypo, element, textRange); + ? strategy.getRegularFixes(element, textRange, useRename, wordWithTypo, suggestions) + : SpellcheckingStrategy.getDefaultRegularFixes(useRename, wordWithTypo, element, textRange, suggestions); - final ProblemDescriptor problemDescriptor = createProblemDescriptor(element, textRange, fixes, true); + ProblemDescriptor problemDescriptor = createProblemDescriptor(element, textRange, fixes, true); holder.registerProblem(problemDescriptor); } @@ -362,7 +362,7 @@ public final class GrazieSpellCheckingInspection extends SpellCheckingInspection } private static void registerProblem(@NotNull SpellingTypo typo, @NotNull ProblemsHolder holder) { - registerProblem(holder, typo.getElement(), typo.getRange(), false, typo.getWord()); + registerProblem(holder, typo.getElement(), typo.getRange(), false, typo.getWord(), typo.getFixes()); } private static void registerProblem(@NotNull ProblemsHolder holder, @@ -370,8 +370,17 @@ public final class GrazieSpellCheckingInspection extends SpellCheckingInspection @NotNull TextRange range, boolean useRename, String word) { + registerProblem(holder, element, range, useRename, word, null); + } + + private static void registerProblem(@NotNull ProblemsHolder holder, + @NotNull PsiElement element, + @NotNull TextRange range, + boolean useRename, + String word, + Set suggestions) { if (holder.isOnTheFly()) { - addRegularDescriptor(element, range, holder, useRename, word); + addRegularDescriptor(element, range, holder, useRename, word, suggestions); } else { addBatchDescriptor(element, range, word, holder); diff --git a/plugins/grazie/src/main/kotlin/com/intellij/grazie/spellcheck/GrazieTextLevelSpellCheckingExtension.kt b/plugins/grazie/src/main/kotlin/com/intellij/grazie/spellcheck/GrazieTextLevelSpellCheckingExtension.kt index fef1dd101545..2b16ea1165a4 100644 --- a/plugins/grazie/src/main/kotlin/com/intellij/grazie/spellcheck/GrazieTextLevelSpellCheckingExtension.kt +++ b/plugins/grazie/src/main/kotlin/com/intellij/grazie/spellcheck/GrazieTextLevelSpellCheckingExtension.kt @@ -1,5 +1,7 @@ package com.intellij.grazie.spellcheck +import ai.grazie.gec.model.problem.ProblemFix +import ai.grazie.gec.model.problem.SentenceWithProblems import ai.grazie.nlp.langs.Language import ai.grazie.nlp.langs.LanguageWithVariant import ai.grazie.nlp.tokenizer.Tokenizer @@ -8,20 +10,34 @@ import ai.grazie.rules.common.KnownPhrases import ai.grazie.spell.Speller import ai.grazie.spell.text.TextSpeller import ai.grazie.spell.text.Typo +import ai.grazie.text.exclusions.SentenceWithExclusions +import ai.grazie.utils.LinkedSet +import ai.grazie.utils.toLinkedSet import com.intellij.codeInspection.LocalInspectionToolSession import com.intellij.grazie.GrazieConfig +import com.intellij.grazie.cloud.GrazieCloudConnector import com.intellij.grazie.ide.inspection.grammar.GrazieInspection.Companion.sortByPriority +import com.intellij.grazie.mlec.LanguageHolder +import com.intellij.grazie.rule.SentenceBatcher import com.intellij.grazie.spellcheck.engine.GrazieSpellCheckerEngine import com.intellij.grazie.text.ProblemFilter import com.intellij.grazie.text.TextContent import com.intellij.grazie.text.TextExtractor +import com.intellij.grazie.utils.NaturalTextDetector +import com.intellij.grazie.utils.getProblems +import com.intellij.grazie.utils.toProofreadingContext +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.runBlockingCancellable import com.intellij.openapi.project.Project import com.intellij.openapi.util.Key import com.intellij.openapi.util.TextRange import com.intellij.openapi.util.getOrCreateUserData +import com.intellij.openapi.util.registry.Registry import com.intellij.openapi.util.text.StringUtil.BombedCharSequence import com.intellij.psi.PsiElement +import com.intellij.spellchecker.SpellCheckerManager import com.intellij.spellchecker.inspections.IdentifierSplitter.MINIMAL_TYPO_LENGTH import com.intellij.spellchecker.tokenizer.SpellcheckingStrategy import com.intellij.util.containers.ContainerUtil @@ -58,7 +74,7 @@ object GrazieTextLevelSpellCheckingExtension { val textSpeller = getTextSpeller(element.project) ?: return SpellCheckingResult.Ignored filteredTexts.asSequence() - .map { it to findTypos(it, session, textSpeller) } + .map { it to findTypos(it, session, textSpeller, element.project) } .flatMap { mapTypo(it.first, it.second, element) } .filterNot { it.word.length < MINIMAL_TYPO_LENGTH } .forEach { consumer.accept(it) } @@ -99,30 +115,94 @@ object GrazieTextLevelSpellCheckingExtension { private fun mapTypo(text: TextContent, typos: List, element: PsiElement): List { val psiRange = element.textRange - return typos.mapNotNull { - val range = text.textRangeToFile(mapRange(it.range)) + return typos.mapNotNull { typo -> + val range = text.textRangeToFile(mapRange(typo.range)) if (!psiRange.contains(range)) return@mapNotNull null - createTypo(it.word, range.shiftLeft(element.textRange.startOffset), element) + createTypo(typo.word, range.shiftLeft(element.textRange.startOffset), element) { + if (typo is CloudTypo) typo.fixes else LinkedSet() + } } } private fun mapRange(range: ai.grazie.text.TextRange): TextRange = TextRange(range.start, range.endExclusive) - private fun findTypos(text: TextContent, session: LocalInspectionToolSession, textSpeller: TextSpeller): List { + private fun findTypos(text: TextContent, session: LocalInspectionToolSession, textSpeller: TextSpeller, project: Project): List { val typos = session.getOrCreateUserData(KEY_TYPO_CACHE) { ConcurrentHashMap() } - return typos.computeIfAbsent(text) { - textSpeller.checkText(object : BombedCharSequence(text) { - override fun checkCanceled() { - ProgressManager.checkCanceled() - } - }) - } + val detectedTypos = typos[text] + if (detectedTypos != null) return detectedTypos + val localTypos = textSpeller.checkText(object : BombedCharSequence(text) { + override fun checkCanceled() { + ProgressManager.checkCanceled() + } + }) + return typos.computeIfAbsent(text) { findTyposInCloud(text, localTypos, project) } } - private fun createTypo(word: String, range: TextRange, element: PsiElement) = object : SpellingTypo { + private fun findTyposInCloud(text: TextContent, localTypos: List, project: Project): List { + if (!Registry.`is`("spellchecker.cloud.enabled", false) + || localTypos.isEmpty() + || !GrazieCloudConnector.seemsCloudConnected() + || GrazieCloudConnector.isAfterRecentGecError() + || !NaturalTextDetector.seemsNatural(text.toString())) { + return localTypos + } + + val context = text.toProofreadingContext() + val cloudTypos = runBlockingCancellable { getProblems(context, SpellServerBatcherHolder::class.java) } + if (cloudTypos == null) return localTypos + + val manager = SpellCheckerManager.getInstance(project) + return cloudTypos + .mapNotNull { + val parts = it.fixes.flatMap { fix -> fix.parts.toList() } + .filterIsInstance() + .filter { part -> part.type == ProblemFix.Part.Change.ChangeType.REPLACE } + if (parts.isEmpty()) return@mapNotNull null + + val word = parts.first().range.substring(text.toString()) + if (!manager.hasProblem(word)) return@mapNotNull null + + CloudTypo(word, parts.first().range, parts.map { part -> part.text }.toLinkedSet()) + } + } + + private fun createTypo(word: String, range: TextRange, element: PsiElement, lazyFixes: () -> LinkedSet) = object : SpellingTypo { override val word: String = word override val range: TextRange = range override val element: PsiElement = element + override val fixes: LinkedSet = lazyFixes() + } + + private class CloudTypo(override val word: String, override val range: ai.grazie.text.TextRange, override val fixes: LinkedSet): Typo + + @Service + private class SpellServerBatcherHolder : LanguageHolder>() { + private class ServerBatcher( + language: Language, + ) : SentenceBatcher(language, 32), Disposable { + override suspend fun parse(sentences: List, project: Project): Map? { + return GrazieCloudConnector.EP_NAME.extensionList + .firstNotNullOfOrNull { it.spell(sentences, language, project) } + ?.zip(sentences) + ?.associate { it.second to it.first } + } + + override fun dispose() {} + + init { + GrazieConfig.subscribe(this) { clearCache() } + GrazieCloudConnector.EP_NAME.forEachExtensionSafe { it.subscribeToAuthorizationStateEvents(this) { clearCache() } } + } + } + + init { + update(mapOf( + Language.ENGLISH to ServerBatcher(Language.ENGLISH), + Language.UKRAINIAN to ServerBatcher(Language.UKRAINIAN), + Language.GERMAN to ServerBatcher(Language.GERMAN), + Language.RUSSIAN to ServerBatcher(Language.RUSSIAN) + )) + } } } @@ -136,6 +216,9 @@ interface SpellingTypo { /** Element that contains a misspelled [word] within the given text [range] */ val element: PsiElement + + /** Suggested corrections for the [word], possibly calculated lazily */ + val fixes: LinkedSet } enum class SpellCheckingResult { Checked, Ignored } diff --git a/plugins/grazie/src/main/kotlin/com/intellij/grazie/utils/GrazieUtils.kt b/plugins/grazie/src/main/kotlin/com/intellij/grazie/utils/GrazieUtils.kt index c1a28f6c2cc5..756532b35a0f 100644 --- a/plugins/grazie/src/main/kotlin/com/intellij/grazie/utils/GrazieUtils.kt +++ b/plugins/grazie/src/main/kotlin/com/intellij/grazie/utils/GrazieUtils.kt @@ -1,6 +1,8 @@ package com.intellij.grazie.utils +import ai.grazie.gec.model.problem.Problem import ai.grazie.gec.model.problem.ProblemHighlighting +import ai.grazie.gec.model.problem.SentenceWithProblems import ai.grazie.nlp.langs.Language import ai.grazie.nlp.langs.Language.UNKNOWN import ai.grazie.rules.Rule @@ -11,8 +13,11 @@ import com.intellij.grazie.GrazieConfig import com.intellij.grazie.detection.LangDetector import com.intellij.grazie.ide.ui.configurable.StyleConfigurable.Companion.ruleEngineLanguages import com.intellij.grazie.jlanguage.LangTool +import com.intellij.grazie.mlec.LanguageHolder import com.intellij.grazie.rule.RuleIdeClient -import com.intellij.grazie.text.TextChecker +import com.intellij.grazie.rule.SentenceBatcher +import com.intellij.grazie.rule.SentenceBatcher.Companion.runWithSentenceBatcher +import com.intellij.grazie.rule.SentenceTokenizer.tokenize import com.intellij.grazie.text.TextChecker.ProofreadingContext import com.intellij.grazie.text.TextContent import com.intellij.openapi.util.TextRange @@ -65,6 +70,27 @@ fun TextContent.toProofreadingContext(): ProofreadingContext { } } +suspend fun >> getProblems(context: ProofreadingContext, parserClass: Class): List? { + val stripPrefixLength = context.stripPrefix.length + val subText = context.text.subText(TextRange(stripPrefixLength, context.text.length)) ?: return emptyList() + val sentences = tokenize(subText) + val parsed = runWithSentenceBatcher(sentences, context.language, context.text.containingFile.viewProvider, parserClass) + if (parsed == null) return null + if (parsed.isEmpty()) return emptyList() + + val result = ArrayList() + for (sentence in sentences) { + val corrections = parsed[sentence.swe()]?.problems ?: continue + val start = sentence.start + stripPrefixLength + if (!context.text.hasUnknownFragmentsIn(TextRange.from(start, sentence.text.trimEnd().length))) { + corrections.forEach { + result.add(it.withOffset(start)) + } + } + } + return result +} + val ProblemHighlighting.underline: TextRange? get() = GrazieTextRange.coveringIde(this.always) diff --git a/spellchecker/src/com/intellij/spellchecker/quickfixes/ChangeTo.kt b/spellchecker/src/com/intellij/spellchecker/quickfixes/ChangeTo.kt index 03d2be6f79ff..45b5a064995c 100644 --- a/spellchecker/src/com/intellij/spellchecker/quickfixes/ChangeTo.kt +++ b/spellchecker/src/com/intellij/spellchecker/quickfixes/ChangeTo.kt @@ -31,7 +31,8 @@ internal class ChangeTo @JvmOverloads constructor( element: PsiElement, private val range: TextRange, private val tracker: SpellcheckerRateTracker? = null, -) : DefaultIntentionActionWithChoice, LazySuggestions(typo) { + suggestions: Set? = null, +) : DefaultIntentionActionWithChoice, LazySuggestions(typo, suggestions) { private val pointer = SmartPointerManager.getInstance(element.project).createSmartPsiElementPointer(element, element.containingFile) diff --git a/spellchecker/src/com/intellij/spellchecker/quickfixes/LazySuggestions.java b/spellchecker/src/com/intellij/spellchecker/quickfixes/LazySuggestions.java index 53b1db1a5308..bf42bb560a0c 100644 --- a/spellchecker/src/com/intellij/spellchecker/quickfixes/LazySuggestions.java +++ b/spellchecker/src/com/intellij/spellchecker/quickfixes/LazySuggestions.java @@ -4,16 +4,22 @@ package com.intellij.spellchecker.quickfixes; import com.intellij.openapi.project.Project; import com.intellij.spellchecker.SpellCheckerManager; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.List; +import java.util.Set; public abstract class LazySuggestions { private List suggestions; private boolean processed; protected final String typo; - public LazySuggestions(String typo) { + public LazySuggestions(String typo, @Nullable Set suggestions) { this.typo = typo; + if (suggestions != null) { + this.suggestions = List.copyOf(suggestions); + this.processed = !this.suggestions.isEmpty(); + } } public @NotNull List getSuggestions(Project project) { diff --git a/spellchecker/src/com/intellij/spellchecker/quickfixes/SpellCheckerQuickFixFactory.kt b/spellchecker/src/com/intellij/spellchecker/quickfixes/SpellCheckerQuickFixFactory.kt index cad40d6730e3..f5061f4c6b16 100644 --- a/spellchecker/src/com/intellij/spellchecker/quickfixes/SpellCheckerQuickFixFactory.kt +++ b/spellchecker/src/com/intellij/spellchecker/quickfixes/SpellCheckerQuickFixFactory.kt @@ -25,9 +25,15 @@ abstract class SpellCheckerQuickFixFactory { @JvmStatic @JvmOverloads - fun changeToVariants(element: PsiElement, rangeInElement: TextRange, word: String, tracker: SpellcheckerRateTracker? = null): List { + fun changeToVariants( + element: PsiElement, + rangeInElement: TextRange, + word: String, + tracker: SpellcheckerRateTracker? = null, + suggestions: Set? = null, + ): List { return EP_NAME.extensionList.firstNotNullOfOrNull { it.createChangeToVariantsFixes(element, rangeInElement, word) } - ?: ChangeTo(word, element, rangeInElement, tracker).getAllAsFixes() + ?: ChangeTo(word, element, rangeInElement, tracker, suggestions).getAllAsFixes() } @JvmStatic diff --git a/spellchecker/src/com/intellij/spellchecker/tokenizer/SpellcheckingStrategy.java b/spellchecker/src/com/intellij/spellchecker/tokenizer/SpellcheckingStrategy.java index d3c6b30ec1de..96c1897e7458 100644 --- a/spellchecker/src/com/intellij/spellchecker/tokenizer/SpellcheckingStrategy.java +++ b/spellchecker/src/com/intellij/spellchecker/tokenizer/SpellcheckingStrategy.java @@ -26,10 +26,7 @@ import com.intellij.util.KeyedLazyInstance; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Set; +import java.util.*; import java.util.regex.Pattern; /** @@ -144,11 +141,21 @@ public class SpellcheckingStrategy implements PossiblyDumbAware { && InjectedLanguageUtil.hasInjections((PsiLanguageInjectionHost)element); } + // Used by 3rd party plugins + @SuppressWarnings("unused") public LocalQuickFix[] getRegularFixes(@NotNull PsiElement element, @NotNull TextRange textRange, boolean useRename, String typo) { - return getDefaultRegularFixes(useRename, typo, element, textRange); + return getDefaultRegularFixes(useRename, typo, element, textRange, null); + } + + public LocalQuickFix[] getRegularFixes(@NotNull PsiElement element, + @NotNull TextRange textRange, + boolean useRename, + String typo, + @Nullable Set suggestions) { + return getDefaultRegularFixes(useRename, typo, element, textRange, suggestions); } public static SpellcheckingStrategy getSpellcheckingStrategy(@NotNull PsiElement element) { @@ -164,14 +171,15 @@ public class SpellcheckingStrategy implements PossiblyDumbAware { public static LocalQuickFix[] getDefaultRegularFixes(boolean useRename, String typo, @NotNull PsiElement element, - @NotNull TextRange range) { + @NotNull TextRange range, + @Nullable Set suggestions) { ArrayList result = new ArrayList<>(); SpellcheckerRateTracker tracker = new SpellcheckerRateTracker(element); if (useRename && PsiTreeUtil.getNonStrictParentOfType(element, PsiNamedElement.class) != null) { result.add(SpellCheckerQuickFixFactory.rename(typo, range, element, tracker)); } else { - List fixes = SpellCheckerQuickFixFactory.changeToVariants(element, range, typo, tracker); + List fixes = SpellCheckerQuickFixFactory.changeToVariants(element, range, typo, tracker, suggestions); result.addAll(fixes); }