From d01aa9e583f624638540b2311ae31d36b554aca3 Mon Sep 17 00:00:00 2001 From: Ilia Permiashkin Date: Tue, 3 Feb 2026 18:00:40 +0000 Subject: [PATCH] [grazie] IJPL-231421 Add a quick fix to report proofreading bugs to YouTrack Co-authored-by: Peter Gromov Merge-request: IJ-MR-190085 Merged-by: Ilia Permiashkin GitOrigin-RevId: 3e05ad86630c12d3b2845b85cec4c0824ab2d5b6 --- .../grammar/quickfix/GrazieYtReportAction.kt | 138 ++++++++++++++++++ .../com/intellij/grazie/mlec/MlecChecker.kt | 91 ++++++------ .../com/intellij/grazie/text/CheckerRunner.kt | 3 + .../messages/GrazieBundle.properties | 12 +- 4 files changed, 199 insertions(+), 45 deletions(-) create mode 100644 plugins/grazie/src/main/kotlin/com/intellij/grazie/ide/inspection/grammar/quickfix/GrazieYtReportAction.kt diff --git a/plugins/grazie/src/main/kotlin/com/intellij/grazie/ide/inspection/grammar/quickfix/GrazieYtReportAction.kt b/plugins/grazie/src/main/kotlin/com/intellij/grazie/ide/inspection/grammar/quickfix/GrazieYtReportAction.kt new file mode 100644 index 000000000000..587bfbc371bf --- /dev/null +++ b/plugins/grazie/src/main/kotlin/com/intellij/grazie/ide/inspection/grammar/quickfix/GrazieYtReportAction.kt @@ -0,0 +1,138 @@ +package com.intellij.grazie.ide.inspection.grammar.quickfix + +import ai.grazie.nlp.langs.Language +import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo +import com.intellij.codeInspection.IntentionAndQuickFixAction +import com.intellij.codeInspection.ProblemDescriptor +import com.intellij.codeInspection.util.IntentionFamilyName +import com.intellij.codeInspection.util.IntentionName +import com.intellij.grazie.GrazieBundle +import com.intellij.grazie.cloud.GrazieCloudConnector.Companion.seemsCloudConnected +import com.intellij.grazie.mlec.MlecChecker +import com.intellij.grazie.spellcheck.TypoProblem +import com.intellij.grazie.text.GrazieProblem +import com.intellij.grazie.text.TextProblem +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.ide.plugins.PluginManager +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Iconable +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiFile +import com.intellij.psi.SmartPointerManager +import com.intellij.psi.SmartPsiFileRange +import org.jetbrains.annotations.Nls +import java.net.URLEncoder +import javax.swing.Icon +import kotlin.math.abs + +private const val NEW_ISSUE_URL = "https://youtrack.jetbrains.com/newissue" +private val SUPPORTED_LANGUAGES = setOf(Language.ENGLISH, Language.GERMAN) +private val TRIM_REGEX = Regex("^(?:\\s*\\n)+|(?:\\s*\\n)+$") +private val SUPPORTED_CLASSES = setOf(TypoProblem::class.java, GrazieProblem::class.java) + +internal class GrazieYtReportAction(problem: TextProblem) : IntentionAndQuickFixAction(), Iconable { + private val naturalLanguage: Language = problem.rule.language + private val programmingLanguages = problem.text.containingFile.viewProvider.languages + private val isCloud = seemsCloudConnected() + private val message = problem.shortMessage + private val clazz: Class<*> = problem.javaClass + private val elementRange: SmartPsiFileRange + private val textRange: SmartPsiFileRange + + init { + val file = problem.text.containingFile + val manager = SmartPointerManager.getInstance(file.project) + elementRange = manager.createSmartPsiFileRangePointer(file, problem.text.commonParent.textRange) + textRange = manager.createSmartPsiFileRangePointer(file, problem.text.rangesInFile.reduce(TextRange::union)) + } + + // maybe expensive, so invoke only if fix is applied + private val title by lazy { + GrazieBundle.message( + "grazie.report.bug.${if (problem.isSpellingProblem) "spelling" else "grammar"}", + words.joinToString(), + ) + } + private val words by lazy { problem.highlightRanges.map { it.substring(problem.text.toString()) } } + @get:Nls + private val problemKind by lazy { + if (problem.isSpellingProblem) GrazieBundle.message("grazie.report.bug.spelling.name") + else if (problem is MlecChecker.MlecProblem) problem.errorText + else problem.rule.globalId + } + private val suggestions by lazy { problem.suggestions.map { it.presentableText } } + + + override fun generatePreview(project: Project, previewDescriptor: ProblemDescriptor): IntentionPreviewInfo = IntentionPreviewInfo.EMPTY + override fun getName(): @IntentionName String = familyName + override fun getFamilyName(): @IntentionFamilyName String = GrazieBundle.message("grazie.report.bug.action.name") + override fun getIcon(flags: Int): Icon = AllIcons.ToolbarDecorator.AddYouTrack + override fun applyFix(project: Project, file: PsiFile?, editor: Editor?) { + val problemText = extractAndTrimText(editor) ?: return + BrowserUtil.browse(generateReportURL(problemText)) + } + override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean = + editor != null && + clazz in SUPPORTED_CLASSES && + naturalLanguage in SUPPORTED_LANGUAGES && + programmingLanguages + .mapNotNull { PluginManager.getPluginByClass(it.javaClass) } + .let { it.isNotEmpty() && it.all { descriptor -> PluginManagerCore.isDevelopedByJetBrains(descriptor)} } + + private fun generateReportURL(problemText: String): String { + val description = buildString { + appendLine("**File's languages:** ${programmingLanguages.joinToString(", ") { it.displayName }}") + appendLine("**Text:**\n```\n$problemText\n```") + appendLine("**Highlighted words:** ${words.joinToString(" … ")}") + appendLine("**Message:** $message") + appendLine("**Suggestions:** ${suggestions.joinToString(", ")}") + appendLine("**Problem Kind:** $problemKind") + appendLine("**Processing:** ${if (isCloud) "Cloud" else "Local"}") + appendLine("**${GrazieBundle.message("grazie.report.bug.additional.information")}:**") + } + + val paramsString = buildList { + add("project=IJPL") + add("summary=${URLEncoder.encode(title, "UTF-8")}") + add("c=${URLEncoder.encode("Subsystem Tools. Natural Languages", "UTF-8")}") + add("c=${URLEncoder.encode("Affected versions ${ApplicationInfo.getInstance().fullVersion}", "UTF-8")}") + add("description=${URLEncoder.encode(description, "UTF-8")}") + } + + return buildString { + append(NEW_ISSUE_URL) + if (paramsString.isNotEmpty()) { + append("?") + append(paramsString.joinToString("&")) + } + } + } + + private fun extractAndTrimText(editor: Editor?): String? = extractText(editor)?.replace(TRIM_REGEX, "") + + private fun extractText(editor: Editor?): String? { + val editor = editor ?: return null + val document = editor.document + val elementRange = elementRange.range?.let(TextRange::create) + val textRange = textRange.range?.let(TextRange::create) + if (elementRange == null && textRange == null) return null + if (elementRange != null && textRange == null) return document.getText(elementRange) + if (elementRange == null && textRange != null) return extractWithAdditionalLines(document, textRange) + if (abs(elementRange!!.length - textRange!!.length) < 100) { + return document.getText(elementRange) + } + return extractWithAdditionalLines(document, textRange) + } + + private fun extractWithAdditionalLines(document: Document, range: TextRange): String { + val startLine = (document.getLineNumber(range.startOffset) - 1).coerceAtLeast(0) + val endLine = (document.getLineNumber(range.endOffset) + 1).coerceAtMost(document.lineCount - 1) + val expandedRange = TextRange(document.getLineStartOffset(startLine), document.getLineEndOffset(endLine)) + return document.getText(expandedRange) + } +} 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 417854db9d0a..16a28d57b2e9 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,5 +1,6 @@ package com.intellij.grazie.mlec +import ai.grazie.gec.model.problem.Problem import ai.grazie.gec.model.problem.ProblemFix import ai.grazie.gec.model.problem.SentenceWithProblems import ai.grazie.nlp.langs.Language @@ -14,6 +15,7 @@ import com.intellij.grazie.text.ExternalTextChecker import com.intellij.grazie.text.GrazieProblem import com.intellij.grazie.text.Rule import com.intellij.grazie.text.RuleGroup +import com.intellij.grazie.text.TextContent import com.intellij.grazie.text.TextProblem import com.intellij.grazie.utils.Text import com.intellij.grazie.utils.TextStyleDomain @@ -29,13 +31,9 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.TextRange import com.intellij.openapi.util.text.StringUtil import java.util.Locale +import kotlin.String class MlecChecker : ExternalTextChecker() { - private val incompleteSentenceMessages = setOf( - "Missing verb", "Missing auxiliary verb", "Missing pronoun", "Incorrect subject-verb form", "Incorrect grammar", - "Incorrect verb form", "Incorrect noun form", "Multiple mistakes", "Incorrect verb tense form" - ) - override fun getRules(locale: Locale): List { val targetLanguage = locale.language ?: return emptyList() val (_, rules) = Constants.mlecRules.entries.find { it.key.locale.language == targetLanguage } ?: return emptyList() @@ -44,53 +42,18 @@ class MlecChecker : ExternalTextChecker() { override suspend fun checkExternally(context: ProofreadingContext): Collection { if (!GrazieCloudConnector.seemsCloudConnected() || !context.shouldCheckGrammarStyle()) return emptyList() - val rules = Constants.mlecRules[context.language] ?: return emptyList() if (rules.none { it.isCurrentlyEnabled(context.text) }) return emptyList() val typos = getProblems(context, MlecServerBatcherHolder::class.java)?.takeIf { it.isNotEmpty() } ?: return emptyList() - return typos .mapNotNull { typo -> val underline: TextRange = typo.highlighting.underline ?: return@mapNotNull null - - val fixes = typo.fixes.map { it.display() } - val errorText = underline.substring(context.text.toString()) - val rule = if (context.language == Language.ENGLISH && typo.info.id.id.endsWith("article.missing")) Constants.enMissingArticle else rules.last() if (!rule.isCurrentlyEnabled(context.text)) return@mapNotNull null - - object : GrazieProblem(typo, rule, context.text) { - override fun getDescriptionTemplate(isOnTheFly: Boolean): @InspectionMessage String { - val description = super.getDescriptionTemplate(isOnTheFly) - if (ApplicationManager.getApplication().isUnitTestMode()) return "${rule.globalId}: $description" - return description - } - - override fun fitsGroup(group: RuleGroup): Boolean { - if (RuleGroup.SENTENCE_START_CASE in group.rules && StringUtil.capitalize(errorText) in fixes) { - val prev = context.text.subSequence(0, underline.startOffset).toString().trim() - if (prev.isBlank() || - Text.Latin.isEndPunctuation(prev) || - Text.findParagraphRange(context.text, underline).startOffset == underline.startOffset) { - return true - } - } - - if (RuleGroup.SENTENCE_END_PUNCTUATION in group.rules && "$errorText." in fixes) { - return context.text.subSequence(underline.endOffset, context.text.length).isBlank() || - Text.findParagraphRange(context.text, underline).endOffset == underline.endOffset - } - - if (RuleGroup.INCOMPLETE_SENTENCE in group.rules && typo.message in incompleteSentenceMessages) { - return true - } - - return super.fitsGroup(group) - } - } + MlecProblem(typo, rule, context.text, underline) } .filterNot { isKnownMlecBug(it) } } @@ -110,15 +73,15 @@ class MlecChecker : ExternalTextChecker() { @Suppress("NonAsciiCharacters") object Constants { - const val MLEC_RULE_PREFIX = "Grazie.MLEC" - val enMissingArticle = mlecRule( + const val MLEC_RULE_PREFIX: String = "Grazie.MLEC" + val enMissingArticle: Rule = mlecRule( "$MLEC_RULE_PREFIX.En.MissingArticle", Language.ENGLISH, presentableName = "Missing article check (ML)", category = "Machine Learning", description = "Missing article detection powered by Grazie machine learning" ) - val mlecRules = mapOf( + val mlecRules: Map> = mapOf( Language.ENGLISH to listOf( enMissingArticle, mlecRule( @@ -191,4 +154,44 @@ class MlecChecker : ExternalTextChecker() { } } + class MlecProblem(source: Problem, rule: Rule, text: TextContent, private val underline: TextRange) : GrazieProblem(source, rule, text) { + companion object { + private val incompleteSentenceMessages = setOf( + "Missing verb", "Missing auxiliary verb", "Missing pronoun", "Incorrect subject-verb form", "Incorrect grammar", + "Incorrect verb form", "Incorrect noun form", "Multiple mistakes", "Incorrect verb tense form" + ) + } + + override fun getDescriptionTemplate(isOnTheFly: Boolean): @InspectionMessage String { + val description = super.getDescriptionTemplate(isOnTheFly) + if (ApplicationManager.getApplication().isUnitTestMode()) return "${rule.globalId}: $description" + return description + } + + override fun fitsGroup(group: RuleGroup): Boolean { + val fixes = source.fixes.map { it.display() } + val errorText = errorText + if (RuleGroup.SENTENCE_START_CASE in group.rules && StringUtil.capitalize(errorText) in fixes) { + val prev = text.subSequence(0, underline.startOffset).toString().trim() + if (prev.isBlank() || + Text.Latin.isEndPunctuation(prev) || + Text.findParagraphRange(text, underline).startOffset == underline.startOffset) { + return true + } + } + + if (RuleGroup.SENTENCE_END_PUNCTUATION in group.rules && "$errorText." in fixes) { + return text.subSequence(underline.endOffset, text.length).isBlank() || + Text.findParagraphRange(text, underline).endOffset == underline.endOffset + } + + if (RuleGroup.INCOMPLETE_SENTENCE in group.rules && source.message in incompleteSentenceMessages) { + return true + } + + return super.fitsGroup(group) + } + + val errorText: String get() = underline.substring(text.toString()) + } } diff --git a/plugins/grazie/src/main/kotlin/com/intellij/grazie/text/CheckerRunner.kt b/plugins/grazie/src/main/kotlin/com/intellij/grazie/text/CheckerRunner.kt index 8d389d9dbf9c..7c587dac587b 100644 --- a/plugins/grazie/src/main/kotlin/com/intellij/grazie/text/CheckerRunner.kt +++ b/plugins/grazie/src/main/kotlin/com/intellij/grazie/text/CheckerRunner.kt @@ -12,6 +12,7 @@ import com.intellij.codeInspection.ProblemDescriptorBase import com.intellij.codeInspection.ProblemHighlightType import com.intellij.codeInspection.util.InspectionMessage import com.intellij.grazie.cloud.GrazieCloudConnector.Companion.seemsCloudConnected +import com.intellij.grazie.grammar.LanguageToolChecker import com.intellij.grazie.ide.fus.AcceptanceRateTracker import com.intellij.grazie.ide.fus.GrazieFUSCounter import com.intellij.grazie.ide.inspection.grammar.GrazieInspection @@ -21,6 +22,7 @@ import com.intellij.grazie.ide.inspection.grammar.quickfix.GrazieEnableCloudActi import com.intellij.grazie.ide.inspection.grammar.quickfix.GrazieMassApplyAction import com.intellij.grazie.ide.inspection.grammar.quickfix.GrazieReplaceTypoQuickFix import com.intellij.grazie.ide.inspection.grammar.quickfix.GrazieRuleSettingsAction +import com.intellij.grazie.ide.inspection.grammar.quickfix.GrazieYtReportAction import com.intellij.grazie.ide.language.LanguageGrammarChecking import com.intellij.grazie.spellcheck.TypoProblem import com.intellij.grazie.text.TextChecker.ProofreadingContext @@ -242,6 +244,7 @@ class CheckerRunner(val text: TextContent) { result.add(GrazieRuleSettingsAction(problem.rule, problem.text.getTextDomain())) } result.add(GrazieMassApplyAction()) + result.add(GrazieYtReportAction(problem)) result.add(GrazieEnableCloudAction()) return result.toTypedArray() } diff --git a/plugins/grazie/src/main/resources/messages/GrazieBundle.properties b/plugins/grazie/src/main/resources/messages/GrazieBundle.properties index 88640310fb57..409ea8b8ae65 100644 --- a/plugins/grazie/src/main/resources/messages/GrazieBundle.properties +++ b/plugins/grazie/src/main/resources/messages/GrazieBundle.properties @@ -241,4 +241,14 @@ grazie.mass.apply.dialog.editor.redo=Redo grazie.mass.apply.text.do=Performing write action\u2026 grazie.mass.apply.text.diff.style.name=Style grazie.mass.apply.text.diff.grammar.name=Grammar -grazie.mass.apply.text.diff.typo.name=Typo \ No newline at end of file +grazie.mass.apply.text.diff.typo.name=Typo + + +# Report proofreading bugs to YouTrack +grazie.report.bug.action.name=Report proofreading issues to YouTrack +grazie.report.bug.spelling.name=Typo +# suppress inspection "UnusedProperty" +grazie.report.bug.spelling=Unknown word(s): ''{0}'' +# suppress inspection "UnusedProperty" +grazie.report.bug.grammar=Wrong proofreading warning: ''{0}'' +grazie.report.bug.additional.information=Additional information