another attempt to fix plugin spellchecker

This commit is contained in:
sergeych committed 2026-01-12 16:05:46 +01:00
1 parent d2632cb99e
commit 017111827d
6 files changed
+83 -103

No files matched your search

@@ -62,6 +62,7 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
if (collectedInfo == null) return null
ProgressManager.checkCanceled()
val text = collectedInfo.text
val tokens = try { SimpleLyngHighlighter().highlight(text) } catch (_: Throwable) { emptyList() }
// Use LyngAstManager to get the (potentially merged) Mini-AST
val mini = LyngAstManager.getMiniAst(collectedInfo.file)
@@ -224,8 +225,6 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
// Heuristics on top of binder: function call-sites and simple name-based roles
ProgressManager.checkCanceled()
val tokens = try { SimpleLyngHighlighter().highlight(text) } catch (_: Throwable) { emptyList() }
// Build simple name -> role map for top-level vals/vars and parameters
val nameRole = HashMap<String, com.intellij.openapi.editor.colors.TextAttributesKey>(8)
mini.declarations.forEach { d ->
@@ -280,7 +279,6 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
// Add annotation/label coloring using token highlighter
run {
val tokens = try { SimpleLyngHighlighter().highlight(text) } catch (_: Throwable) { emptyList() }
tokens.forEach { s ->
if (s.kind == HighlightKind.Label) {
val start = s.range.start
@@ -322,7 +320,6 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
// Map Enum constants from token highlighter to IDEA enum constant color
run {
val tokens = try { SimpleLyngHighlighter().highlight(text) } catch (_: Throwable) { emptyList() }
tokens.forEach { s ->
if (s.kind == HighlightKind.EnumConstant) {
val start = s.range.start
@@ -334,24 +331,10 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
}
}
// Build spell index payload: identifiers from symbols + references; comments/strings from simple highlighter
val idRanges = mutableSetOf<IntRange>()
try {
val binding = Binder.bind(text, mini)
binding.symbols.forEach { sym ->
val s = sym.declStart
val e = sym.declEnd
if (s in 0..e && e <= text.length && s < e) idRanges += (s until e)
}
binding.references.forEach { ref ->
val s = ref.start
val e = ref.end
if (s in 0..e && e <= text.length && s < e) idRanges += (s until e)
}
} catch (_: Throwable) {
// Best-effort; no identifiers if binder fails
}
val tokens = try { SimpleLyngHighlighter().highlight(text) } catch (_: Throwable) { emptyList() }
// Build spell index payload: identifiers + comments/strings from simple highlighter.
// We use the highlighter as the source of truth for all "words" to check, including
// identifiers that might not be bound by the Binder.
val idRanges = tokens.filter { it.kind == HighlightKind.Identifier }.map { it.range.start until it.range.endExclusive }
val commentRanges = tokens.filter { it.kind == HighlightKind.Comment }.map { it.range.start until it.range.endExclusive }
val stringRanges = tokens.filter { it.kind == HighlightKind.String }.map { it.range.start until it.range.endExclusive }
@@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -150,16 +150,18 @@ class LyngGrazieAnnotator : ExternalAnnotator<LyngGrazieAnnotator.Input, LyngGra
}
log.info("LyngGrazieAnnotator.apply: used=${chosenEntry ?: "<none>"}, totalFindings=$totalReturned, painting=${findings.size}")
// IMPORTANT: Do NOT fallback to the tiny bundled vocabulary on modern IDEs.
// If Grazie/Natural Languages processing returned nothing, we simply exit here
// to avoid low‑quality results from the legacy dictionary.
if (findings.isEmpty()) return
for (f in findings) {
val ab = holder.newAnnotation(HighlightSeverity.INFORMATION, f.message).range(f.range)
applyTypoStyleIfRequested(file, ab)
ab.create()
}
// SUPPLEMENT: Always run the fallback spellchecker to ensure spelling errors are not ignored.
// It will avoid duplicating findings already reported by Grazie.
val painted = fallbackWithLegacySpellcheckerIfAvailable(file, fragments, holder, findings)
if (painted > 0) {
log.info("LyngGrazieAnnotator.apply: supplemented with $painted typos from legacy engine")
}
}
private fun scheduleOneShotRestart(file: PsiFile, modStamp: Long) {
@@ -381,7 +383,8 @@ class LyngGrazieAnnotator : ExternalAnnotator<LyngGrazieAnnotator.Input, LyngGra
private fun fallbackWithLegacySpellcheckerIfAvailable(
file: PsiFile,
fragments: List<Pair<TextContent, TextRange>>,
holder: AnnotationHolder
holder: AnnotationHolder,
existingFindings: List<Finding>
): Int {
return try {
val mgrCls = Class.forName("com.intellij.spellchecker.SpellCheckerManager")
@@ -389,12 +392,12 @@ class LyngGrazieAnnotator : ExternalAnnotator<LyngGrazieAnnotator.Input, LyngGra
val isCorrect = mgrCls.methods.firstOrNull { it.name == "isCorrect" && it.parameterCount == 1 && it.parameterTypes[0] == String::class.java }
if (getInstance == null || isCorrect == null) {
// No legacy spellchecker API available — fall back to naive painter
return naiveFallbackPaint(file, fragments, holder)
return naiveFallbackPaint(file, fragments, holder, existingFindings)
}
val mgr = getInstance.invoke(null, file.project)
if (mgr == null) {
// Legacy manager not present for this project — use naive fallback
return naiveFallbackPaint(file, fragments, holder)
return naiveFallbackPaint(file, fragments, holder, existingFindings)
}
var painted = 0
val docText = file.viewProvider.document?.text ?: return 0
@@ -411,13 +414,18 @@ class LyngGrazieAnnotator : ExternalAnnotator<LyngGrazieAnnotator.Input, LyngGra
for (part in parts) {
if (part.length <= 2) continue
if (isAllowedWord(part)) continue
// Map part back to original token occurrence within this hostRange
val localStart = m.range.first + token.indexOf(part)
val localEnd = localStart + part.length
val abs = TextRange(hostRange.startOffset + localStart, hostRange.startOffset + localEnd)
// Avoid duplicating findings from Grazie
if (existingFindings.any { it.range.intersects(abs) }) continue
// Quick allowlist for very common words to reduce noise if dictionaries differ
val ok = try { isCorrect.invoke(mgr, part) as? Boolean } catch (_: Throwable) { null }
if (ok == false) {
// Map part back to original token occurrence within this hostRange
val localStart = m.range.first + token.indexOf(part)
val localEnd = localStart + part.length
val abs = TextRange(hostRange.startOffset + localStart, hostRange.startOffset + localEnd)
paintTypoAnnotation(file, holder, abs, part)
painted++
flagged++
@@ -430,14 +438,15 @@ class LyngGrazieAnnotator : ExternalAnnotator<LyngGrazieAnnotator.Input, LyngGra
painted
} catch (_: Throwable) {
// If legacy manager is not available, fall back to a very naive heuristic (no external deps)
return naiveFallbackPaint(file, fragments, holder)
return naiveFallbackPaint(file, fragments, holder, existingFindings)
}
}
private fun naiveFallbackPaint(
file: PsiFile,
fragments: List<Pair<TextContent, TextRange>>,
holder: AnnotationHolder
holder: AnnotationHolder,
existingFindings: List<Finding>
): Int {
var painted = 0
val docText = file.viewProvider.document?.text
@@ -461,6 +470,14 @@ class LyngGrazieAnnotator : ExternalAnnotator<LyngGrazieAnnotator.Input, LyngGra
seen++
val lower = part.lowercase()
if (lower.length <= 2 || isAllowedWord(part)) continue
val localStart = m.range.first + token.indexOf(part)
val localEnd = localStart + part.length
val abs = TextRange(hostRange.startOffset + localStart, hostRange.startOffset + localEnd)
// Avoid duplicating findings from Grazie
if (existingFindings.any { it.range.intersects(abs) }) continue
// Heuristic: no vowels OR 3 repeated chars OR ends with unlikely double consonants
val noVowel = lower.none { it in "aeiouy" }
val triple = Regex("(.)\\1\\1").containsMatchIn(lower)
@@ -480,9 +497,6 @@ class LyngGrazieAnnotator : ExternalAnnotator<LyngGrazieAnnotator.Input, LyngGra
}
}
if (looksWrong) {
val localStart = m.range.first + token.indexOf(part)
val localEnd = localStart + part.length
val abs = TextRange(hostRange.startOffset + localStart, hostRange.startOffset + localEnd)
paintTypoAnnotation(file, holder, abs, part)
painted++
flagged++
@@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -101,8 +101,9 @@ class LyngLexer : LexerBase() {
return
}
// String "..." with simple escape handling
if (ch == '"') {
// String "..." or '...' with simple escape handling
if (ch == '"' || ch == '\'') {
val quote = ch
i++
while (i < endOffset) {
val c = buffer[i]
@@ -110,7 +111,7 @@ class LyngLexer : LexerBase() {
i += 2
continue
}
if (c == '"') { i++; break }
if (c == quote) { i++; break }
i++
}
myTokenEnd = i
@@ -17,9 +17,6 @@
package net.sergeych.lyng.idea.spell
// Avoid Tokenizers helper to keep compatibility; implement our own tokenizers
import com.intellij.ide.plugins.PluginManagerCore
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.extensions.PluginId
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiElement
import com.intellij.spellchecker.inspections.PlainTextSplitter
@@ -38,67 +35,21 @@ import net.sergeych.lyng.idea.settings.LyngFormatterSettings
*/
class LyngSpellcheckingStrategy : SpellcheckingStrategy() {
private val log = Logger.getInstance(LyngSpellcheckingStrategy::class.java)
@Volatile private var loggedOnce = false
private fun grazieInstalled(): Boolean {
// Support both historical and bundled IDs
return PluginManagerCore.isPluginInstalled(PluginId.getId("com.intellij.grazie")) ||
PluginManagerCore.isPluginInstalled(PluginId.getId("tanvd.grazi"))
}
private fun grazieApiAvailable(): Boolean = try {
// If this class is absent (as in IC-243), third-party plugins can't run Grazie programmatically
Class.forName("com.intellij.grazie.grammar.GrammarChecker")
true
} catch (_: Throwable) { false }
override fun getTokenizer(element: PsiElement): Tokenizer<*> {
if (element is com.intellij.psi.PsiFile) return EMPTY_TOKENIZER
val hasGrazie = grazieInstalled()
val hasGrazieApi = grazieApiAvailable()
val settings = LyngFormatterSettings.getInstance(element.project)
if (!loggedOnce) {
loggedOnce = true
log.info("LyngSpellcheckingStrategy activated: hasGrazie=$hasGrazie, grazieApi=$hasGrazieApi, preferGrazieForCommentsAndLiterals=${settings.preferGrazieForCommentsAndLiterals}, spellCheckStringLiterals=${settings.spellCheckStringLiterals}, grazieChecksIdentifiers=${settings.grazieChecksIdentifiers}")
}
val file = element.containingFile ?: return EMPTY_TOKENIZER
val et = element.node?.elementType
val index = LyngSpellIndex.getUpToDate(file)
// Decide responsibility per settings
// If Grazie is present but its public API is not available (IC-243), do NOT delegate to it.
val preferGrazie = hasGrazie && hasGrazieApi && settings.preferGrazieForCommentsAndLiterals
val grazieIds = hasGrazie && hasGrazieApi && settings.grazieChecksIdentifiers
if (index == null) {
// Index not ready: fall back to Lexer-based token types.
// Identifiers are safe because LyngLexer separates keywords from identifiers.
if (et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.IDENTIFIER) {
return if (grazieIds) EMPTY_TOKENIZER else IDENTIFIER_TOKENIZER
}
if (et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.LINE_COMMENT || et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.BLOCK_COMMENT) {
return if (preferGrazie) EMPTY_TOKENIZER else COMMENT_TEXT_TOKENIZER
}
if (et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.STRING && settings.spellCheckStringLiterals) {
return if (preferGrazie) EMPTY_TOKENIZER else STRING_WITH_PRINTF_EXCLUDES
}
return EMPTY_TOKENIZER
if (et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.IDENTIFIER || et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.LABEL) {
return IDENTIFIER_TOKENIZER
}
if (et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.LINE_COMMENT || et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.BLOCK_COMMENT) {
return COMMENT_TEXT_TOKENIZER
}
if (et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.STRING && settings.spellCheckStringLiterals) {
return STRING_WITH_PRINTF_EXCLUDES
}
val elRange = element.textRange ?: return EMPTY_TOKENIZER
fun overlaps(list: List<TextRange>) = list.any { it.intersects(elRange) }
// Identifiers: only if range is within identifiers index and not delegated to Grazie
if (et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.IDENTIFIER && overlaps(index.identifiers) && !grazieIds) return IDENTIFIER_TOKENIZER
// Comments: only if not delegated to Grazie and overlapping indexed comments
if ((et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.LINE_COMMENT || et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.BLOCK_COMMENT) && overlaps(index.comments) && !preferGrazie) return COMMENT_TEXT_TOKENIZER
// Strings: only if not delegated to Grazie, literals checking enabled, and overlapping indexed strings
if (et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.STRING && settings.spellCheckStringLiterals && overlaps(index.strings) && !preferGrazie) return STRING_WITH_PRINTF_EXCLUDES
return EMPTY_TOKENIZER
}