[grazie] IJPL-231421 Add a quick fix to report proofreading bugs to YouTrack

Co-authored-by: Peter Gromov <peter.gromov@jetbrains.com>

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

GitOrigin-RevId: 3e05ad86630c12d3b2845b85cec4c0824ab2d5b6
This commit is contained in:
Ilia Permiashkin
2026-02-03 18:00:40 +00:00
committed by intellij-monorepo-bot
co-authored by Peter Gromov
parent fb009ba68b
commit d01aa9e583
4 changed files with 199 additions and 45 deletions
@@ -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)
}
}
@@ -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<Rule> {
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<TextProblem> {
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<Language, List<Rule>> = 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())
}
}
@@ -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()
}
@@ -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
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