From 2e17297355cae9203405877b6a7212fb629004d5 Mon Sep 17 00:00:00 2001 From: sergeych Date: Wed, 3 Dec 2025 01:29:34 +0100 Subject: [PATCH] Add Grazie-backed grammar checker and dictionary support to Lyng IDEA plugin and variants for replacements with or without Grazie interfaces --- lyng-idea/build.gradle.kts | 6 +- .../idea/annotators/LyngExternalAnnotator.kt | 55 +- .../idea/grazie/AddToLyngDictionaryFix.kt | 43 ++ .../lyng/idea/grazie/EnglishDictionary.kt | 83 +++ .../lyng/idea/grazie/LyngGrazieAnnotator.kt | 598 ++++++++++++++++++ .../lyng/idea/grazie/LyngGrazieStrategy.kt | 139 ++++ .../lyng/idea/grazie/LyngTextExtractor.kt | 104 +++ .../lyng/idea/grazie/ReplaceWordFix.kt | 86 +++ .../lyng/idea/grazie/TechDictionary.kt | 77 +++ .../idea/settings/LyngFormatterSettings.kt | 54 ++ .../LyngFormatterSettingsConfigurable.kt | 43 +- .../lyng/idea/spell/LyngSpellIndex.kt | 50 ++ .../idea/spell/LyngSpellcheckingStrategy.kt | 155 +++++ .../resources/META-INF/grazie-bundled.xml | 29 + .../main/resources/META-INF/grazie-lite.xml | 30 + .../src/main/resources/META-INF/grazie.xml | 28 + .../src/main/resources/META-INF/plugin.xml | 10 + .../main/resources/META-INF/spellchecker.xml | 29 + .../main/resources/dictionaries/en-basic.txt | 466 ++++++++++++++ .../main/resources/dictionaries/tech-lyng.txt | 282 +++++++++ 20 files changed, 2363 insertions(+), 4 deletions(-) create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/AddToLyngDictionaryFix.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/EnglishDictionary.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieAnnotator.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieStrategy.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngTextExtractor.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/ReplaceWordFix.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/TechDictionary.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/spell/LyngSpellIndex.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/spell/LyngSpellcheckingStrategy.kt create mode 100644 lyng-idea/src/main/resources/META-INF/grazie-bundled.xml create mode 100644 lyng-idea/src/main/resources/META-INF/grazie-lite.xml create mode 100644 lyng-idea/src/main/resources/META-INF/grazie.xml create mode 100644 lyng-idea/src/main/resources/META-INF/spellchecker.xml create mode 100644 lyng-idea/src/main/resources/dictionaries/en-basic.txt create mode 100644 lyng-idea/src/main/resources/dictionaries/tech-lyng.txt diff --git a/lyng-idea/build.gradle.kts b/lyng-idea/build.gradle.kts index defb653..791ee77 100644 --- a/lyng-idea/build.gradle.kts +++ b/lyng-idea/build.gradle.kts @@ -49,7 +49,11 @@ intellij { updateSinceUntilBuild.set(false) // Include only available bundled plugins for this IDE build plugins.set(listOf( - "com.intellij.java" + "com.intellij.java", + // Provide Grazie API on compile classpath (bundled in 2024.3+, but add here for compilation) + "tanvd.grazi" + // Do not list com.intellij.spellchecker here: it is expected to be bundled with the IDE. + // Listing it causes Gradle to search for a separate plugin artifact and fail on IC 2024.3. )) } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt index 77f2147..53d1d70 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt @@ -47,7 +47,10 @@ class LyngExternalAnnotator : ExternalAnnotator, val error: Error? = null) + data class Result(val modStamp: Long, val spans: List, val error: Error? = null, + val spellIdentifiers: List = emptyList(), + val spellComments: List = emptyList(), + val spellStrings: List = emptyList()) override fun collectInformation(file: PsiFile): Input? { val doc: Document = file.viewProvider.document ?: return null @@ -240,7 +243,29 @@ class LyngExternalAnnotator : ExternalAnnotator() + try { + val binding = Binder.bind(text, mini) + for (sym in binding.symbols) { + val s = sym.declStart; val e = sym.declEnd + if (s in 0..e && e <= text.length && s < e) idRanges += (s until e) + } + for (ref in binding.references) { + 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() } + 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 } + + return Result(collectedInfo.modStamp, out, null, + spellIdentifiers = idRanges.toList(), + spellComments = commentRanges, + spellStrings = stringRanges) } override fun apply(file: PsiFile, annotationResult: Result?, holder: AnnotationHolder) { @@ -252,6 +277,32 @@ class LyngExternalAnnotator : ExternalAnnotator = emptySet() + + /** + * Load dictionary from bundled resources (once). + * If multiple candidates exist, the first found is used. + */ + private fun ensureLoaded() { + if (loaded) return + synchronized(this) { + if (loaded) return + val candidates = listOf( + // preferred large bundles first (add en-basic.txt.gz ~3–5MB here) + "/dictionaries/en-basic.txt.gz", + "/dictionaries/en-large.txt.gz", + // plain text fallbacks + "/dictionaries/en-basic.txt", + "/dictionaries/en-large.txt", + ) + val merged = HashSet(128_000) + for (res in candidates) { + try { + val stream = javaClass.getResourceAsStream(res) ?: continue + val reader = if (res.endsWith(".gz")) + BufferedReader(InputStreamReader(GZIPInputStream(stream))) + else + BufferedReader(InputStreamReader(stream)) + var loadedCount = 0 + reader.useLines { seq -> seq.forEach { line -> + val w = line.trim() + if (w.isNotEmpty() && !w.startsWith("#")) { merged += w.lowercase(); loadedCount++ } + } } + log.info("EnglishDictionary: loaded $loadedCount words from $res (total=${merged.size})") + } catch (t: Throwable) { + log.info("EnglishDictionary: failed to load $res: ${t.javaClass.simpleName}: ${t.message}") + } + } + if (merged.isEmpty()) { + // Fallback minimal set + merged += setOf("comment","comments","error","errors","found","file","not","word","words","count","value","name","class","function","string") + log.info("EnglishDictionary: using minimal built-in set (${merged.size})") + } + words = merged + loaded = true + } + } + + fun allWords(): Set { + ensureLoaded() + return words + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieAnnotator.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieAnnotator.kt new file mode 100644 index 0000000..aa46bc9 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieAnnotator.kt @@ -0,0 +1,598 @@ +/* + * Copyright 2025 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +/* + * Grazie-backed annotator for Lyng files. + * + * It consumes the MiniAst-driven LyngSpellIndex and, when Grazie is present, + * tries to run Grazie checks on the extracted TextContent. Results are painted + * as warnings in the editor. If the Grazie API changes, we use reflection and + * fail softly with INFO logs (no errors shown to users). + */ +package net.sergeych.lyng.idea.grazie + +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer +import com.intellij.grazie.text.TextContent +import com.intellij.grazie.text.TextContent.TextDomain +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.lang.annotation.AnnotationHolder +import com.intellij.lang.annotation.ExternalAnnotator +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.colors.TextAttributesKey +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiFile +import net.sergeych.lyng.idea.settings.LyngFormatterSettings +import net.sergeych.lyng.idea.spell.LyngSpellIndex + +class LyngGrazieAnnotator : ExternalAnnotator(), DumbAware { + private val log = Logger.getInstance(LyngGrazieAnnotator::class.java) + + data class Input(val modStamp: Long) + data class Finding(val range: TextRange, val message: String) + data class Result(val modStamp: Long, val findings: List) + + override fun collectInformation(file: PsiFile): Input? { + val doc: Document = file.viewProvider.document ?: return null + // Only require Grazie presence; index readiness is checked in apply with a retry. + val grazie = isGrazieInstalled() + if (!grazie) { + log.info("LyngGrazieAnnotator.collectInformation: skip (grazie=false) file='${file.name}'") + return null + } + log.info("LyngGrazieAnnotator.collectInformation: file='${file.name}', modStamp=${doc.modificationStamp}") + return Input(doc.modificationStamp) + } + + override fun doAnnotate(collectedInfo: Input?): Result? { + // All heavy lifting is done in apply where we have the file context + return collectedInfo?.let { Result(it.modStamp, emptyList()) } + } + + override fun apply(file: PsiFile, annotationResult: Result?, holder: AnnotationHolder) { + if (annotationResult == null || !isGrazieInstalled()) return + val doc = file.viewProvider.document ?: return + val idx = LyngSpellIndex.getUpToDate(file) ?: run { + log.info("LyngGrazieAnnotator.apply: index not ready for '${file.name}', scheduling one-shot restart") + scheduleOneShotRestart(file, annotationResult.modStamp) + return + } + + val settings = LyngFormatterSettings.getInstance(file.project) + + // Build TextContent fragments for comments/strings/identifiers according to settings + val fragments = mutableListOf>() + try { + fun addFragments(ranges: List, domain: TextDomain) { + for (r in ranges) { + val local = rangeToTextContent(file, domain, r) ?: continue + fragments += local to r + } + } + // Comments always via COMMENTS + addFragments(idx.comments, TextDomain.COMMENTS) + // Strings: LITERALS if requested, else COMMENTS if fallback enabled + if (settings.spellCheckStringLiterals) { + val domain = if (settings.grazieTreatLiteralsAsComments) TextDomain.COMMENTS else TextDomain.LITERALS + addFragments(idx.strings, domain) + } + // Identifiers via COMMENTS to force painting in 243 unless user disables fallback + val idsDomain = if (settings.grazieTreatIdentifiersAsComments) TextDomain.COMMENTS else TextDomain.DOCUMENTATION + addFragments(idx.identifiers, idsDomain) + log.info( + "LyngGrazieAnnotator.apply: file='${file.name}', idxCounts ids=${idx.identifiers.size}, comments=${idx.comments.size}, strings=${idx.strings.size}, builtFragments=${fragments.size}" + ) + } catch (e: Throwable) { + log.info("LyngGrazieAnnotator: failed to build TextContent fragments: ${e.javaClass.simpleName}: ${e.message}") + return + } + + if (fragments.isEmpty()) return + + val findings = mutableListOf() + var totalReturned = 0 + var chosenEntry: String? = null + for ((content, hostRange) in fragments) { + try { + val (typos, entryNote) = runGrazieChecksWithTracing(file, content) + if (chosenEntry == null) chosenEntry = entryNote + if (typos != null) { + totalReturned += typos.size + for (t in typos) { + val rel = extractRangeFromTypo(t) ?: continue + // Map relative range inside fragment to host file range + val abs = TextRange(hostRange.startOffset + rel.startOffset, hostRange.startOffset + rel.endOffset) + findings += Finding(abs, extractMessageFromTypo(t) ?: "Spelling/Grammar") + } + } + } catch (e: Throwable) { + log.info("LyngGrazieAnnotator: Grazie check failed: ${e.javaClass.simpleName}: ${e.message}") + } + } + log.info("LyngGrazieAnnotator.apply: used=${chosenEntry ?: ""}, totalFindings=$totalReturned, painting=${findings.size}") + + // Guarded fallback: if Grazie returned nothing but we clearly have fragments, try legacy spellchecker via reflection + if (findings.isEmpty() && fragments.isNotEmpty()) { + val added = fallbackWithLegacySpellcheckerIfAvailable(file, fragments, holder) + log.info("LyngGrazieAnnotator.apply: fallback painted=$added (0 means no legacy and heuristic may have painted separately)") + // Ensure at least one visible mark for diagnostics: paint a small WARNING at the first fragment range + try { + val first = fragments.first().second + val diagRange = TextRange(first.startOffset, (first.startOffset + 2).coerceAtMost(first.endOffset)) + val ab = holder.newAnnotation(HighlightSeverity.INFORMATION, "[LyngSpell] active").range(diagRange) + applyTypoStyleIfRequested(file, ab) + ab.create() + log.info("LyngGrazieAnnotator.apply: painted diagnostic marker at ${diagRange.startOffset}..${diagRange.endOffset}") + } catch (_: Throwable) { + // ignore + } + } + + for (f in findings) { + val ab = holder.newAnnotation(HighlightSeverity.INFORMATION, f.message).range(f.range) + applyTypoStyleIfRequested(file, ab) + ab.create() + } + } + + private fun scheduleOneShotRestart(file: PsiFile, modStamp: Long) { + try { + val last = file.getUserData(RETRY_KEY) + if (last == modStamp) { + log.info("LyngGrazieAnnotator.restart: already retried for modStamp=$modStamp, skip") + return + } + file.putUserData(RETRY_KEY, modStamp) + ApplicationManager.getApplication().invokeLater({ + try { + DaemonCodeAnalyzer.getInstance(file.project).restart(file) + log.info("LyngGrazieAnnotator.restart: daemon restarted for '${file.name}'") + } catch (e: Throwable) { + log.info("LyngGrazieAnnotator.restart failed: ${e.javaClass.simpleName}: ${e.message}") + } + }) + } catch (e: Throwable) { + log.info("LyngGrazieAnnotator.scheduleOneShotRestart failed: ${e.javaClass.simpleName}: ${e.message}") + } + } + + private fun isGrazieInstalled(): Boolean { + return PluginManagerCore.isPluginInstalled(com.intellij.openapi.extensions.PluginId.getId("com.intellij.grazie")) || + PluginManagerCore.isPluginInstalled(com.intellij.openapi.extensions.PluginId.getId("tanvd.grazi")) + } + + private fun rangeToTextContent(file: PsiFile, domain: TextDomain, range: TextRange): TextContent? { + // Build TextContent via reflection: prefer psiFragment(domain, element) + return try { + // Try to find an element that fully covers the target range + var element = file.findElementAt(range.startOffset) ?: return null + val start = range.startOffset + val end = range.endOffset + while (element.parent != null && (element.textRange.startOffset > start || element.textRange.endOffset < end)) { + element = element.parent + } + if (element.textRange.startOffset > start || element.textRange.endOffset < end) return null + // In many cases, the element may not span the whole range; use file + range via suitable factory + val methods = TextContent::class.java.methods.filter { it.name == "psiFragment" } + val byElementDomain = methods.firstOrNull { it.parameterCount == 2 && it.parameterTypes[0].name.endsWith("PsiElement") } + if (byElementDomain != null) { + @Suppress("UNCHECKED_CAST") + return (byElementDomain.invoke(null, element, domain) as? TextContent)?.let { tc -> + val relStart = start - element.textRange.startOffset + val relEnd = end - element.textRange.startOffset + if (relStart < 0 || relEnd > tc.length || relStart >= relEnd) return null + tc.subText(TextRange(relStart, relEnd)) + } + } + val byDomainElement = methods.firstOrNull { it.parameterCount == 2 && it.parameterTypes[0].name.endsWith("TextDomain") } + if (byDomainElement != null) { + @Suppress("UNCHECKED_CAST") + return (byDomainElement.invoke(null, domain, element) as? TextContent)?.let { tc -> + val relStart = start - element.textRange.startOffset + val relEnd = end - element.textRange.startOffset + if (relStart < 0 || relEnd > tc.length || relStart >= relEnd) return null + tc.subText(TextRange(relStart, relEnd)) + } + } + null + } catch (e: Throwable) { + log.info("LyngGrazieAnnotator: rangeToTextContent failed: ${e.javaClass.simpleName}: ${e.message}") + null + } + } + + private fun runGrazieChecksWithTracing(file: PsiFile, content: TextContent): Pair?, String?> { + // Try known entry points via reflection to avoid hard dependencies on Grazie internals + try { + // 1) Static GrammarChecker.check(TextContent) + val checkerCls = try { Class.forName("com.intellij.grazie.grammar.GrammarChecker") } catch (t: Throwable) { + log.info("LyngGrazieAnnotator: GrammarChecker class not found: ${t.javaClass.simpleName}: ${t.message}") + null + } + if (checkerCls != null) { + // Diagnostic: list available 'check' methods once + runCatching { + val checks = checkerCls.methods.filter { it.name == "check" } + val sig = checks.joinToString { m -> + val params = m.parameterTypes.joinToString(prefix = "(", postfix = ")") { it.simpleName } + "${m.name}$params static=${java.lang.reflect.Modifier.isStatic(m.modifiers)}" + } + log.info("LyngGrazieAnnotator: GrammarChecker.check candidates: ${if (sig.isEmpty()) "" else sig}") + } + checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") }?.let { m -> + @Suppress("UNCHECKED_CAST") + val res = m.invoke(null, content) as? Collection + return res to "GrammarChecker.check(TextContent) static" + } + // 2) GrammarChecker.getInstance().check(TextContent) + val getInstance = checkerCls.methods.firstOrNull { it.name == "getInstance" && it.parameterCount == 0 } + val inst = getInstance?.invoke(null) + if (inst != null) { + val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") } + if (m != null) { + @Suppress("UNCHECKED_CAST") + val res = m.invoke(inst, content) as? Collection + return res to "GrammarChecker.getInstance().check(TextContent)" + } + } + // 3) GrammarChecker.getDefault().check(TextContent) + val getDefault = checkerCls.methods.firstOrNull { it.name == "getDefault" && it.parameterCount == 0 } + val def = getDefault?.invoke(null) + if (def != null) { + val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") } + if (m != null) { + @Suppress("UNCHECKED_CAST") + val res = m.invoke(def, content) as? Collection + return res to "GrammarChecker.getDefault().check(TextContent)" + } + } + // 4) Service from project/application: GrammarChecker as a service + runCatching { + val app = com.intellij.openapi.application.ApplicationManager.getApplication() + val getService = app::class.java.methods.firstOrNull { it.name == "getService" && it.parameterCount == 1 } + val svc = getService?.invoke(app, checkerCls) + if (svc != null) { + val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") } + if (m != null) { + @Suppress("UNCHECKED_CAST") + val res = m.invoke(svc, content) as? Collection + if (res != null) return res to "Application.getService(GrammarChecker).check(TextContent)" + } + } + } + runCatching { + val getService = file.project::class.java.methods.firstOrNull { it.name == "getService" && it.parameterCount == 1 } + val svc = getService?.invoke(file.project, checkerCls) + if (svc != null) { + val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") } + if (m != null) { + @Suppress("UNCHECKED_CAST") + val res = m.invoke(svc, content) as? Collection + if (res != null) return res to "Project.getService(GrammarChecker).check(TextContent)" + } + } + } + } + // 5) Fallback: search any public method named check that accepts TextContent in any Grazie class (static) + val candidateClasses = listOf( + "com.intellij.grazie.grammar.GrammarChecker", + "com.intellij.grazie.grammar.GrammarRunner", + "com.intellij.grazie.grammar.Grammar" // historical names + ) + for (cn in candidateClasses) { + val cls = try { Class.forName(cn) } catch (_: Throwable) { continue } + val m = cls.methods.firstOrNull { it.name == "check" && it.parameterTypes.any { p -> p.name.endsWith("TextContent") } } + if (m != null) { + val args = arrayOfNulls(m.parameterCount) + // place content to the first TextContent parameter; others left null (common defaults) + for (i in 0 until m.parameterCount) if (m.parameterTypes[i].name.endsWith("TextContent")) { args[i] = content; break } + @Suppress("UNCHECKED_CAST") + val res = m.invoke(null, *args) as? Collection + if (res != null) return res to "$cn.${m.name}(TextContent)" + } + } + // 6) Kotlin top-level function: GrammarCheckerKt.check(TextContent) + runCatching { + val kt = Class.forName("com.intellij.grazie.grammar.GrammarCheckerKt") + val m = kt.methods.firstOrNull { it.name == "check" && it.parameterTypes.any { p -> p.name.endsWith("TextContent") } } + if (m != null) { + val args = arrayOfNulls(m.parameterCount) + for (i in 0 until m.parameterCount) if (m.parameterTypes[i].name.endsWith("TextContent")) { args[i] = content; break } + @Suppress("UNCHECKED_CAST") + val res = m.invoke(null, *args) as? Collection + if (res != null) return res to "GrammarCheckerKt.check(TextContent)" + } + } + } catch (e: Throwable) { + log.info("LyngGrazieAnnotator: runGrazieChecks reflection failed: ${e.javaClass.simpleName}: ${e.message}") + } + return null to null + } + + private fun extractRangeFromTypo(typo: Any): TextRange? { + // Try to get a relative range from returned Grazie issue/typo via common accessors + return try { + // Common getters + val m1 = typo.javaClass.methods.firstOrNull { it.name == "getRange" && it.parameterCount == 0 } + val r1 = if (m1 != null) m1.invoke(typo) else null + when (r1) { + is TextRange -> return r1 + is IntRange -> return TextRange(r1.first, r1.last + 1) + } + val m2 = typo.javaClass.methods.firstOrNull { it.name == "getHighlightRange" && it.parameterCount == 0 } + val r2 = if (m2 != null) m2.invoke(typo) else null + when (r2) { + is TextRange -> return r2 + is IntRange -> return TextRange(r2.first, r2.last + 1) + } + // Separate from/to ints + val fromM = typo.javaClass.methods.firstOrNull { it.name == "getFrom" && it.parameterCount == 0 && it.returnType == Int::class.javaPrimitiveType } + val toM = typo.javaClass.methods.firstOrNull { it.name == "getTo" && it.parameterCount == 0 && it.returnType == Int::class.javaPrimitiveType } + if (fromM != null && toM != null) { + val s = (fromM.invoke(typo) as? Int) ?: return null + val e = (toM.invoke(typo) as? Int) ?: return null + if (e > s) return TextRange(s, e) + } + null + } catch (_: Throwable) { null } + } + + private fun extractMessageFromTypo(typo: Any): String? { + return try { + val m = typo.javaClass.methods.firstOrNull { it.name == "getMessage" && it.parameterCount == 0 } + (m?.invoke(typo) as? String) + } catch (_: Throwable) { null } + } + + companion object { + private val RETRY_KEY: Key = Key.create("LYNG_GRAZIE_ANN_RETRY_STAMP") + } + + // Fallback that uses legacy SpellCheckerManager (if present) via reflection to validate words in fragments. + // Returns number of warnings painted. + private fun fallbackWithLegacySpellcheckerIfAvailable( + file: PsiFile, + fragments: List>, + holder: AnnotationHolder + ): Int { + return try { + val mgrCls = Class.forName("com.intellij.spellchecker.SpellCheckerManager") + val getInstance = mgrCls.methods.firstOrNull { it.name == "getInstance" && it.parameterCount == 1 } + 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) + } + 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) + } + var painted = 0 + val docText = file.viewProvider.document?.text ?: return 0 + val tokenRegex = Regex("[A-Za-z][A-Za-z0-9_']{2,}") + for ((content, hostRange) in fragments) { + val text = try { docText.substring(hostRange.startOffset, hostRange.endOffset) } catch (_: Throwable) { null } ?: continue + var seen = 0 + var flagged = 0 + for (m in tokenRegex.findAll(text)) { + val token = m.value + if ('%' in token) continue // skip printf fragments defensively + // Split snake_case and camelCase within the token + val parts = splitIdentifier(token) + for (part in parts) { + if (part.length <= 2) continue + if (isAllowedWord(part)) 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++ + } + seen++ + } + } + log.info("LyngGrazieAnnotator.fallback: fragment words=$seen, flagged=$flagged") + } + painted + } catch (_: Throwable) { + // If legacy manager is not available, fall back to a very naive heuristic (no external deps) + return naiveFallbackPaint(file, fragments, holder) + } + } + + private fun naiveFallbackPaint( + file: PsiFile, + fragments: List>, + holder: AnnotationHolder + ): Int { + var painted = 0 + val docText = file.viewProvider.document?.text + val tokenRegex = Regex("[A-Za-z][A-Za-z0-9_']{2,}") + val baseWords = setOf( + // small, common vocabulary to catch near-miss typos in typical code/comments + "comment","comments","error","errors","found","file","not","word","words","count","value","name","class","function","string" + ) + for ((content, hostRange) in fragments) { + val text: String? = docText?.let { dt -> + try { dt.substring(hostRange.startOffset, hostRange.endOffset) } catch (_: Throwable) { null } + } + if (text.isNullOrBlank()) continue + var seen = 0 + var flagged = 0 + for (m in tokenRegex.findAll(text)) { + val token = m.value + if ('%' in token) continue + val parts = splitIdentifier(token) + for (part in parts) { + seen++ + val lower = part.lowercase() + if (lower.length <= 2 || isAllowedWord(part)) 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) + val dblCons = Regex("[bcdfghjklmnpqrstvwxyz]{2}$").containsMatchIn(lower) + var looksWrong = noVowel || triple || dblCons + // Additional: low vowel ratio for length>=4 + if (!looksWrong && lower.length >= 4) { + val vowels = lower.count { it in "aeiouy" } + val ratio = if (lower.isNotEmpty()) vowels.toDouble() / lower.length else 1.0 + if (ratio < 0.25) looksWrong = true + } + // Additional: near-miss to a small base vocabulary (edit distance 1, or 2 for words >=6) + if (!looksWrong) { + for (bw in baseWords) { + val d = editDistance(lower, bw) + if (d == 1 || (d == 2 && lower.length >= 6)) { looksWrong = true; break } + } + } + 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++ + } + } + } + log.info("LyngGrazieAnnotator.fallback(naive): fragment words=$seen, flagged=$flagged") + } + return painted + } + + private fun paintTypoAnnotation(file: PsiFile, holder: AnnotationHolder, range: TextRange, word: String) { + val settings = LyngFormatterSettings.getInstance(file.project) + val ab = holder.newAnnotation(HighlightSeverity.INFORMATION, "Possible typo") + .range(range) + applyTypoStyleIfRequested(file, ab) + if (settings.offerLyngTypoQuickFixes) { + // Offer lightweight fixes; for 243 provide Add-to-dictionary always + ab.withFix(net.sergeych.lyng.idea.grazie.AddToLyngDictionaryFix(word)) + // Offer "Replace with…" candidates (top 7) + val cands = suggestReplacements(file, word).take(7) + for (c in cands) { + ab.withFix(net.sergeych.lyng.idea.grazie.ReplaceWordFix(range, word, c)) + } + } + ab.create() + } + + private fun applyTypoStyleIfRequested(file: PsiFile, ab: com.intellij.lang.annotation.AnnotationBuilder) { + val settings = LyngFormatterSettings.getInstance(file.project) + if (!settings.showTyposWithGreenUnderline) return + // Use the standard TYPO text attributes key used by the platform + val TYPO: TextAttributesKey = TextAttributesKey.createTextAttributesKey("TYPO") + try { + ab.textAttributes(TYPO) + } catch (_: Throwable) { + // some IDEs may not allow setting attributes on INFORMATION; ignore gracefully + } + } + + private fun suggestReplacements(file: PsiFile, word: String): List { + val lower = word.lowercase() + val fromProject = collectProjectWords(file) + val fromTech = TechDictionary.allWords() + val fromEnglish = EnglishDictionary.allWords() + // Merge with priority: project (p=0), tech (p=1), english (p=2) + val all = LinkedHashSet() + all.addAll(fromProject) + all.addAll(fromTech) + all.addAll(fromEnglish) + data class Cand(val w: String, val d: Int, val p: Int) + val cands = ArrayList(32) + for (w in all) { + if (w == lower) continue + if (kotlin.math.abs(w.length - lower.length) > 2) continue + val d = editDistance(lower, w) + val p = when { + w in fromProject -> 0 + w in fromTech -> 1 + else -> 2 + } + cands += Cand(w, d, p) + } + cands.sortWith(compareBy { it.d }.thenBy { it.p }.thenBy { it.w }) + // Return a larger pool so callers can choose desired display count + return cands.take(16).map { it.w } + } + + private fun collectProjectWords(file: PsiFile): Set { + // Simple approach: use current file text; can be extended to project scanning later + val text = file.viewProvider.document?.text ?: return emptySet() + val out = LinkedHashSet() + val tokenRegex = Regex("[A-Za-z][A-Za-z0-9_']{2,}") + for (m in tokenRegex.findAll(text)) { + val parts = splitIdentifier(m.value) + parts.forEach { out += it.lowercase() } + } + // Include learned words + val settings = LyngFormatterSettings.getInstance(file.project) + out.addAll(settings.learnedWords.map { it.lowercase() }) + return out + } + + private fun splitIdentifier(token: String): List { + // Split on underscores and camelCase boundaries + val unders = token.split('_').filter { it.isNotBlank() } + val out = mutableListOf() + val camelBoundary = Regex("(?<=[a-z])(?=[A-Z])") + for (u in unders) out += u.split(camelBoundary).filter { it.isNotBlank() } + return out + } + + private fun isAllowedWord(w: String): Boolean { + val s = w.lowercase() + return s in setOf( + // common code words / language keywords to avoid noise + "val","var","fun","class","enum","type","import","package","return","if","else","when","while","for","try","catch","finally","true","false","null", + // very common English words + "the","and","or","not","with","from","into","this","that","file","found","count","name","value","object" + ) + } + + private fun editDistance(a: String, b: String): Int { + if (a == b) return 0 + if (a.isEmpty()) return b.length + if (b.isEmpty()) return a.length + val dp = IntArray(b.length + 1) { it } + for (i in 1..a.length) { + var prev = dp[0] + dp[0] = i + for (j in 1..b.length) { + val temp = dp[j] + dp[j] = minOf( + dp[j] + 1, // deletion + dp[j - 1] + 1, // insertion + prev + if (a[i - 1] == b[j - 1]) 0 else 1 // substitution + ) + prev = temp + } + } + return dp[b.length] + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieStrategy.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieStrategy.kt new file mode 100644 index 0000000..0cab36f --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieStrategy.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2025 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package net.sergeych.lyng.idea.grazie + +import com.intellij.grazie.grammar.strategy.GrammarCheckingStrategy +import com.intellij.grazie.grammar.strategy.GrammarCheckingStrategy.TextDomain +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 net.sergeych.lyng.idea.highlight.LyngTokenTypes +import net.sergeych.lyng.idea.settings.LyngFormatterSettings +import net.sergeych.lyng.idea.spell.LyngSpellIndex + +/** + * Grazie/Natural Languages strategy for Lyng. + * + * - Comments: checked as natural language (TextDomain.COMMENTS) + * - String literals: optionally checked (setting), skipping printf-like specifiers via stealth ranges (TextDomain.LITERALS) + * - Identifiers (non-keywords): checked under TextDomain.CODE so "Process code" controls apply + * - Keywords: skipped + */ +class LyngGrazieStrategy : GrammarCheckingStrategy { + + private val log = Logger.getInstance(LyngGrazieStrategy::class.java) + @Volatile private var loggedOnce = false + @Volatile private var loggedFirstMatch = false + private val seenTypes: MutableSet = java.util.Collections.synchronizedSet(mutableSetOf()) + + private fun legacySpellcheckerInstalled(): Boolean = + PluginManagerCore.isPluginInstalled(PluginId.getId("com.intellij.spellchecker")) + + // Regex for printf-style specifiers: %[flags][width][.precision][length]type + private val spec = Regex("%(?:[-+ #0]*(?:\\d+)?(?:\\.\\d+)?[a-zA-Z%])") + + override fun isMyContextRoot(element: PsiElement): Boolean { + val type = element.node?.elementType + val settings = LyngFormatterSettings.getInstance(element.project) + val legacyPresent = legacySpellcheckerInstalled() + if (type != null && seenTypes.size < 10) { + val name = type.toString() + if (seenTypes.add(name)) { + log.info("LyngGrazieStrategy: saw PSI type=$name") + } + } + if (!loggedOnce) { + loggedOnce = true + log.info("LyngGrazieStrategy activated: legacyPresent=$legacyPresent, preferGrazieForCommentsAndLiterals=${settings.preferGrazieForCommentsAndLiterals}, spellCheckStringLiterals=${settings.spellCheckStringLiterals}, grazieChecksIdentifiers=${settings.grazieChecksIdentifiers}") + } + + val file = element.containingFile ?: return false + val index = LyngSpellIndex.getUpToDate(file) ?: return false // Suspend until ready + // To ensure Grazie asks TextExtractor for all leafs, accept any Lyng element once index is ready. + // The extractor will decide per-range/domain what to actually provide. + if (!loggedFirstMatch) { + loggedFirstMatch = true + log.info("LyngGrazieStrategy: enabling Grazie on all Lyng elements (index ready)") + } + return true + } + + override fun getContextRootTextDomain(root: PsiElement): TextDomain { + val type = root.node?.elementType + val settings = LyngFormatterSettings.getInstance(root.project) + val file = root.containingFile + val index = if (file != null) LyngSpellIndex.getUpToDate(file) else null + val r = root.textRange + + fun overlaps(list: List): Boolean = r != null && list.any { it.intersects(r) } + + return when (type) { + LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT -> TextDomain.COMMENTS + LyngTokenTypes.STRING -> if (settings.grazieTreatLiteralsAsComments) TextDomain.COMMENTS else TextDomain.LITERALS + LyngTokenTypes.IDENTIFIER -> { + // For Grazie-only reliability in 243, route identifiers via COMMENTS when configured + if (settings.grazieTreatIdentifiersAsComments && index != null && r != null && overlaps(index.identifiers)) + TextDomain.COMMENTS + else TextDomain.PLAIN_TEXT + } + else -> TextDomain.PLAIN_TEXT + } + } + + // Note: do not override getLanguageSupport to keep compatibility with 243 API + + override fun getStealthyRanges(root: PsiElement, text: CharSequence): java.util.LinkedHashSet { + val result = LinkedHashSet() + val type = root.node?.elementType + if (type == LyngTokenTypes.STRING) { + if (!shouldCheckLiterals(root)) { + // Hide the entire string when literals checking is disabled by settings + result += (0 until text.length) + return result + } + // Hide printf-like specifiers in strings + val (start, end) = stripQuotesBounds(text) + if (end > start) { + val content = text.subSequence(start, end) + for (m in spec.findAll(content)) { + val ms = start + m.range.first + val me = start + m.range.last + result += (ms..me) + } + if (result.isNotEmpty()) { + log.debug("LyngGrazieStrategy: hidden ${result.size} printf specifier ranges in string literal") + } + } + } + return result + } + + override fun isEnabledByDefault(): Boolean = true + + private fun shouldCheckLiterals(root: PsiElement): Boolean = + LyngFormatterSettings.getInstance(root.project).spellCheckStringLiterals + + private fun stripQuotesBounds(text: CharSequence): Pair { + if (text.length < 2) return 0 to text.length + val first = text.first() + val last = text.last() + return if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) + 1 to (text.length - 1) else (0 to text.length) + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngTextExtractor.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngTextExtractor.kt new file mode 100644 index 0000000..e1f82f2 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngTextExtractor.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2025 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package net.sergeych.lyng.idea.grazie + +import com.intellij.grazie.text.TextContent +import com.intellij.grazie.text.TextContent.TextDomain +import com.intellij.grazie.text.TextExtractor +import com.intellij.openapi.diagnostic.Logger +import com.intellij.psi.PsiElement +import net.sergeych.lyng.idea.highlight.LyngTokenTypes +import net.sergeych.lyng.idea.settings.LyngFormatterSettings +import net.sergeych.lyng.idea.spell.LyngSpellIndex + +/** + * Provides Grazie with extractable text for Lyng PSI elements. + * We return text for identifiers, comments, and (optionally) string literals. + * printf-like specifiers are filtered by the Grammar strategy via stealth ranges. + */ +class LyngTextExtractor : TextExtractor() { + private val log = Logger.getInstance(LyngTextExtractor::class.java) + @Volatile private var loggedOnce = false + private val seen: MutableSet = java.util.Collections.synchronizedSet(mutableSetOf()) + + override fun buildTextContent(element: PsiElement, allowedDomains: Set): TextContent? { + val type = element.node?.elementType ?: return null + if (!loggedOnce) { + loggedOnce = true + log.info("LyngTextExtractor active; allowedDomains=${allowedDomains.joinToString()}") + } + val settings = LyngFormatterSettings.getInstance(element.project) + val file = element.containingFile + val index = if (file != null) LyngSpellIndex.getUpToDate(file) else null + val r = element.textRange + + fun overlaps(list: List): Boolean = r != null && list.any { it.intersects(r) } + + // Decide target domain by intersection with our MiniAst-driven index; prefer comments > strings > identifiers + var domain: TextDomain? = null + if (index != null && r != null) { + if (overlaps(index.comments)) domain = TextDomain.COMMENTS + else if (overlaps(index.strings) && settings.spellCheckStringLiterals) domain = TextDomain.LITERALS + else if (overlaps(index.identifiers)) domain = if (settings.grazieTreatIdentifiersAsComments) TextDomain.COMMENTS else TextDomain.DOCUMENTATION + } else { + // Fallback to token type if index is not ready (rare timing), mostly for comments + domain = when (type) { + LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT -> TextDomain.COMMENTS + else -> null + } + } + if (domain == null) return null + + // If literals aren't requested but fallback is enabled, route strings as COMMENTS + if (domain == TextDomain.LITERALS && !allowedDomains.contains(TextDomain.LITERALS) && settings.grazieTreatLiteralsAsComments) { + domain = TextDomain.COMMENTS + } + if (!allowedDomains.contains(domain)) { + if (seen.add("deny-${domain.name}")) { + log.info("LyngTextExtractor: domain ${domain.name} not in allowedDomains; skipping") + } + return null + } + return try { + // Try common factory names across versions + val methods = TextContent::class.java.methods.filter { it.name == "psiFragment" } + val built: TextContent? = when { + // Try psiFragment(PsiElement, TextDomain) + methods.any { it.parameterCount == 2 && it.parameterTypes[0].name.contains("PsiElement") } -> { + val m = methods.first { it.parameterCount == 2 && it.parameterTypes[0].name.contains("PsiElement") } + @Suppress("UNCHECKED_CAST") + (m.invoke(null, element, domain) as? TextContent)?.also { + if (seen.add("ok-${domain.name}")) log.info("LyngTextExtractor: provided ${domain.name} for ${type} via psiFragment(element, domain)") + } + } + // Try psiFragment(TextDomain, PsiElement) + methods.any { it.parameterCount == 2 && it.parameterTypes[0].name.endsWith("TextDomain") } -> { + val m = methods.first { it.parameterCount == 2 && it.parameterTypes[0].name.endsWith("TextDomain") } + @Suppress("UNCHECKED_CAST") + (m.invoke(null, domain, element) as? TextContent)?.also { + if (seen.add("ok-${domain.name}")) log.info("LyngTextExtractor: provided ${domain.name} for ${type} via psiFragment(domain, element)") + } + } + else -> null + } + built + } catch (e: Throwable) { + log.info("LyngTextExtractor: failed to build TextContent: ${e.javaClass.simpleName}: ${e.message}") + null + } + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/ReplaceWordFix.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/ReplaceWordFix.kt new file mode 100644 index 0000000..d9382a5 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/ReplaceWordFix.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2025 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package net.sergeych.lyng.idea.grazie + +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.CaretModel +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiFile + +/** + * Lightweight quick-fix to replace a misspelled word (subrange) with a suggested alternative. + * Works without the legacy Spell Checker. The replacement is applied directly to the file text. + */ +class ReplaceWordFix( + private val range: TextRange, + private val original: String, + private val replacementRaw: String +) : IntentionAction { + + override fun getText(): String = "Replace '$original' with '$replacementRaw'" + override fun getFamilyName(): String = "Lyng Spelling" + override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean = + editor != null && file != null && range.startOffset in 0..range.endOffset + + override fun startInWriteAction(): Boolean = true + + override fun invoke(project: Project, editor: Editor?, file: PsiFile?) { + if (editor == null) return + val doc: Document = editor.document + val safeRange = range.constrainTo(doc) + val current = doc.getText(safeRange) + // Preserve basic case style based on the original token + val replacement = adaptCaseStyle(current, replacementRaw) + WriteCommandAction.runWriteCommandAction(project, "Replace word", null, Runnable { + doc.replaceString(safeRange.startOffset, safeRange.endOffset, replacement) + }, file) + // Move caret to end of replacement for convenience + try { + val caret: CaretModel = editor.caretModel + caret.moveToOffset(safeRange.startOffset + replacement.length) + } catch (_: Throwable) {} + // Restart daemon to refresh highlights + if (file != null) DaemonCodeAnalyzer.getInstance(project).restart(file) + } + + private fun TextRange.constrainTo(doc: Document): TextRange { + val start = startOffset.coerceIn(0, doc.textLength) + val end = endOffset.coerceIn(start, doc.textLength) + return TextRange(start, end) + } + + private fun adaptCaseStyle(sample: String, suggestion: String): String { + if (suggestion.isEmpty()) return suggestion + return when { + sample.all { it.isUpperCase() } -> suggestion.uppercase() + // PascalCase / Capitalized single word + sample.firstOrNull()?.isUpperCase() == true && sample.drop(1).any { it.isLowerCase() } -> + suggestion.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + // snake_case -> lower + sample.contains('_') -> suggestion.lowercase() + // camelCase -> lower first + sample.firstOrNull()?.isLowerCase() == true && sample.any { it.isUpperCase() } -> + suggestion.replaceFirstChar { it.lowercase() } + else -> suggestion + } + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/TechDictionary.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/TechDictionary.kt new file mode 100644 index 0000000..b06cb60 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/TechDictionary.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2025 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package net.sergeych.lyng.idea.grazie + +import com.intellij.openapi.diagnostic.Logger +import java.io.BufferedReader +import java.io.InputStreamReader +import java.util.zip.GZIPInputStream + +/** + * Lightweight technical/Lyng vocabulary dictionary. + * Loaded from classpath resources; supports .txt and .txt.gz. Merged with EnglishDictionary. + */ +object TechDictionary { + private val log = Logger.getInstance(TechDictionary::class.java) + @Volatile private var loaded = false + @Volatile private var words: Set = emptySet() + + private fun ensureLoaded() { + if (loaded) return + synchronized(this) { + if (loaded) return + val candidates = listOf( + "/dictionaries/tech-lyng.txt.gz", + "/dictionaries/tech-lyng.txt" + ) + val merged = HashSet(8_000) + for (res in candidates) { + try { + val stream = javaClass.getResourceAsStream(res) ?: continue + val reader = if (res.endsWith(".gz")) + BufferedReader(InputStreamReader(GZIPInputStream(stream))) + else + BufferedReader(InputStreamReader(stream)) + var n = 0 + reader.useLines { seq -> seq.forEach { line -> + val w = line.trim() + if (w.isNotEmpty() && !w.startsWith("#")) { merged += w.lowercase(); n++ } + } } + log.info("TechDictionary: loaded $n words from $res (total=${merged.size})") + } catch (t: Throwable) { + log.info("TechDictionary: failed to load $res: ${t.javaClass.simpleName}: ${t.message}") + } + } + if (merged.isEmpty()) { + merged += setOf( + // minimal Lyng/tech seeding to avoid empty dictionary + "lyng","miniast","binder","printf","specifier","specifiers","regex","token","tokens", + "identifier","identifiers","keyword","keywords","comment","comments","string","strings", + "literal","literals","formatting","formatter","grazie","typo","typos","dictionary","dictionaries" + ) + log.info("TechDictionary: using minimal built-in set (${merged.size})") + } + words = merged + loaded = true + } + } + + fun allWords(): Set { + ensureLoaded() + return words + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettings.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettings.kt index 944c822..d71a8f4 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettings.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettings.kt @@ -32,6 +32,24 @@ class LyngFormatterSettings(private val project: Project) : PersistentStateCompo var reindentClosedBlockOnEnter: Boolean = true, var reindentPastedBlocks: Boolean = true, var normalizeBlockCommentIndent: Boolean = false, + var spellCheckStringLiterals: Boolean = true, + // When Grazie/Natural Languages is present, prefer it for comments and literals (avoid legacy duplicates) + var preferGrazieForCommentsAndLiterals: Boolean = true, + // When Grazie is available, also check identifiers via Grazie. + // Default OFF because Grazie typically doesn't flag code identifiers; legacy Spellchecker is better for code. + var grazieChecksIdentifiers: Boolean = false, + // Grazie-only fallback: treat identifiers as comments domain so Grazie applies spelling rules + var grazieTreatIdentifiersAsComments: Boolean = true, + // Grazie-only fallback: treat string literals as comments domain when LITERALS domain is not requested + var grazieTreatLiteralsAsComments: Boolean = true, + // Debug helper: show the exact ranges we feed to Grazie/legacy as weak warnings + var debugShowSpellFeed: Boolean = false, + // Visuals: render Lyng typos using the standard Typo green underline styling + var showTyposWithGreenUnderline: Boolean = true, + // Enable lightweight quick-fixes (Replace..., Add to dictionary) without legacy Spell Checker + var offerLyngTypoQuickFixes: Boolean = true, + // Per-project learned words (do not flag again) + var learnedWords: MutableSet = mutableSetOf(), ) private var myState: State = State() @@ -62,6 +80,42 @@ class LyngFormatterSettings(private val project: Project) : PersistentStateCompo get() = myState.normalizeBlockCommentIndent set(value) { myState.normalizeBlockCommentIndent = value } + var spellCheckStringLiterals: Boolean + get() = myState.spellCheckStringLiterals + set(value) { myState.spellCheckStringLiterals = value } + + var preferGrazieForCommentsAndLiterals: Boolean + get() = myState.preferGrazieForCommentsAndLiterals + set(value) { myState.preferGrazieForCommentsAndLiterals = value } + + var grazieChecksIdentifiers: Boolean + get() = myState.grazieChecksIdentifiers + set(value) { myState.grazieChecksIdentifiers = value } + + var grazieTreatIdentifiersAsComments: Boolean + get() = myState.grazieTreatIdentifiersAsComments + set(value) { myState.grazieTreatIdentifiersAsComments = value } + + var grazieTreatLiteralsAsComments: Boolean + get() = myState.grazieTreatLiteralsAsComments + set(value) { myState.grazieTreatLiteralsAsComments = value } + + var debugShowSpellFeed: Boolean + get() = myState.debugShowSpellFeed + set(value) { myState.debugShowSpellFeed = value } + + var showTyposWithGreenUnderline: Boolean + get() = myState.showTyposWithGreenUnderline + set(value) { myState.showTyposWithGreenUnderline = value } + + var offerLyngTypoQuickFixes: Boolean + get() = myState.offerLyngTypoQuickFixes + set(value) { myState.offerLyngTypoQuickFixes = value } + + var learnedWords: MutableSet + get() = myState.learnedWords + set(value) { myState.learnedWords = value } + companion object { @JvmStatic fun getInstance(project: Project): LyngFormatterSettings = project.getService(LyngFormatterSettings::class.java) diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettingsConfigurable.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettingsConfigurable.kt index 7e80462..8082d12 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettingsConfigurable.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettingsConfigurable.kt @@ -30,6 +30,12 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur private var reindentClosedBlockCb: JCheckBox? = null private var reindentPasteCb: JCheckBox? = null private var normalizeBlockCommentIndentCb: JCheckBox? = null + private var spellCheckLiteralsCb: JCheckBox? = null + private var preferGrazieCommentsLiteralsCb: JCheckBox? = null + private var grazieChecksIdentifiersCb: JCheckBox? = null + private var grazieIdsAsCommentsCb: JCheckBox? = null + private var grazieLiteralsAsCommentsCb: JCheckBox? = null + private var debugShowSpellFeedCb: JCheckBox? = null override fun getDisplayName(): String = "Lyng Formatter" @@ -41,6 +47,12 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur reindentClosedBlockCb = JCheckBox("Reindent enclosed block on Enter after '}'") reindentPasteCb = JCheckBox("Reindent pasted blocks (align pasted code to current indent)") normalizeBlockCommentIndentCb = JCheckBox("Normalize block comment indentation [experimental]") + spellCheckLiteralsCb = JCheckBox("Spell check string literals (skip % specifiers like %s, %d, %-12s)") + preferGrazieCommentsLiteralsCb = JCheckBox("Prefer Natural Languages/Grazie for comments and string literals (avoid duplicates)") + grazieChecksIdentifiersCb = JCheckBox("Check identifiers via Natural Languages/Grazie when available") + grazieIdsAsCommentsCb = JCheckBox("Natural Languages/Grazie: treat identifiers as comments (forces spelling checks in 2024.3)") + grazieLiteralsAsCommentsCb = JCheckBox("Natural Languages/Grazie: treat string literals as comments when literals are not processed") + debugShowSpellFeedCb = JCheckBox("Debug: show spell-feed ranges (weak warnings)") // Tooltips / short help spacingCb?.toolTipText = "Applies minimal, safe spacing (e.g., around commas/operators, control-flow parens)." @@ -48,11 +60,22 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur reindentClosedBlockCb?.toolTipText = "On Enter after a closing '}', reindent the just-closed {…} block using formatter rules." reindentPasteCb?.toolTipText = "When caret is in leading whitespace, reindent the pasted text and align it to the caret's indent." normalizeBlockCommentIndentCb?.toolTipText = "Experimental: normalize indentation inside /* … */ comments (code is not modified)." + preferGrazieCommentsLiteralsCb?.toolTipText = "When ON and Natural Languages/Grazie is installed, comments and string literals are checked by Grazie. Turn OFF to force legacy Spellchecker to check them." + grazieChecksIdentifiersCb?.toolTipText = "When ON and Natural Languages/Grazie is installed, identifiers (non-keywords) are checked by Grazie too." + grazieIdsAsCommentsCb?.toolTipText = "Grazie-only fallback: route identifiers as COMMENTS domain so Grazie applies spelling in 2024.3." + grazieLiteralsAsCommentsCb?.toolTipText = "Grazie-only fallback: when Grammar doesn't process literals, route strings as COMMENTS so they are checked." + debugShowSpellFeedCb?.toolTipText = "Show the exact ranges we feed to spellcheckers (ids/comments/strings) as weak warnings." p.add(spacingCb) p.add(wrappingCb) p.add(reindentClosedBlockCb) p.add(reindentPasteCb) p.add(normalizeBlockCommentIndentCb) + p.add(spellCheckLiteralsCb) + p.add(preferGrazieCommentsLiteralsCb) + p.add(grazieChecksIdentifiersCb) + p.add(grazieIdsAsCommentsCb) + p.add(grazieLiteralsAsCommentsCb) + p.add(debugShowSpellFeedCb) panel = p reset() return p @@ -64,7 +87,13 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur wrappingCb?.isSelected != s.enableWrapping || reindentClosedBlockCb?.isSelected != s.reindentClosedBlockOnEnter || reindentPasteCb?.isSelected != s.reindentPastedBlocks || - normalizeBlockCommentIndentCb?.isSelected != s.normalizeBlockCommentIndent + normalizeBlockCommentIndentCb?.isSelected != s.normalizeBlockCommentIndent || + spellCheckLiteralsCb?.isSelected != s.spellCheckStringLiterals || + preferGrazieCommentsLiteralsCb?.isSelected != s.preferGrazieForCommentsAndLiterals || + grazieChecksIdentifiersCb?.isSelected != s.grazieChecksIdentifiers || + grazieIdsAsCommentsCb?.isSelected != s.grazieTreatIdentifiersAsComments || + grazieLiteralsAsCommentsCb?.isSelected != s.grazieTreatLiteralsAsComments || + debugShowSpellFeedCb?.isSelected != s.debugShowSpellFeed } override fun apply() { @@ -74,6 +103,12 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur s.reindentClosedBlockOnEnter = reindentClosedBlockCb?.isSelected == true s.reindentPastedBlocks = reindentPasteCb?.isSelected == true s.normalizeBlockCommentIndent = normalizeBlockCommentIndentCb?.isSelected == true + s.spellCheckStringLiterals = spellCheckLiteralsCb?.isSelected == true + s.preferGrazieForCommentsAndLiterals = preferGrazieCommentsLiteralsCb?.isSelected == true + s.grazieChecksIdentifiers = grazieChecksIdentifiersCb?.isSelected == true + s.grazieTreatIdentifiersAsComments = grazieIdsAsCommentsCb?.isSelected == true + s.grazieTreatLiteralsAsComments = grazieLiteralsAsCommentsCb?.isSelected == true + s.debugShowSpellFeed = debugShowSpellFeedCb?.isSelected == true } override fun reset() { @@ -83,5 +118,11 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur reindentClosedBlockCb?.isSelected = s.reindentClosedBlockOnEnter reindentPasteCb?.isSelected = s.reindentPastedBlocks normalizeBlockCommentIndentCb?.isSelected = s.normalizeBlockCommentIndent + spellCheckLiteralsCb?.isSelected = s.spellCheckStringLiterals + preferGrazieCommentsLiteralsCb?.isSelected = s.preferGrazieForCommentsAndLiterals + grazieChecksIdentifiersCb?.isSelected = s.grazieChecksIdentifiers + grazieIdsAsCommentsCb?.isSelected = s.grazieTreatIdentifiersAsComments + grazieLiteralsAsCommentsCb?.isSelected = s.grazieTreatLiteralsAsComments + debugShowSpellFeedCb?.isSelected = s.debugShowSpellFeed } } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/spell/LyngSpellIndex.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/spell/LyngSpellIndex.kt new file mode 100644 index 0000000..d417dcf --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/spell/LyngSpellIndex.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2025 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package net.sergeych.lyng.idea.spell + +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiFile + +/** + * Per-file cached spellcheck index built from MiniAst-based highlighting and the lynglib highlighter. + * It exposes identifier, comment, and string literal ranges. Strategies should suspend until data is ready. + */ +object LyngSpellIndex { + private val LOG = Logger.getInstance(LyngSpellIndex::class.java) + + data class Data( + val modStamp: Long, + val identifiers: List, + val comments: List, + val strings: List, + ) + + private val KEY: Key = Key.create("LYNG_SPELL_INDEX") + + fun getUpToDate(file: PsiFile): Data? { + val doc = file.viewProvider.document ?: return null + val d = file.getUserData(KEY) ?: return null + return if (d.modStamp == doc.modificationStamp) d else null + } + + fun store(file: PsiFile, data: Data) { + file.putUserData(KEY, data) + LOG.info("LyngSpellIndex built: ids=${data.identifiers.size}, comments=${data.comments.size}, strings=${data.strings.size}") + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/spell/LyngSpellcheckingStrategy.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/spell/LyngSpellcheckingStrategy.kt new file mode 100644 index 0000000..51cb183 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/spell/LyngSpellcheckingStrategy.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2025 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +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 +import com.intellij.spellchecker.tokenizer.SpellcheckingStrategy +import com.intellij.spellchecker.tokenizer.TokenConsumer +import com.intellij.spellchecker.tokenizer.Tokenizer +import net.sergeych.lyng.idea.settings.LyngFormatterSettings + +/** + * Spellchecking strategy for Lyng: + * - Identifiers: checked as identifiers + * - Comments: checked as plain text + * - Keywords: skipped + * - String literals: optional (controlled by settings), and we exclude printf-style format specifiers like + * %s, %d, %-12s, %0.2f, etc. + */ +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<*> { + 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 index = LyngSpellIndex.getUpToDate(file) ?: run { + // Suspend legacy spellcheck until MiniAst-based index is ready + return EMPTY_TOKENIZER + } + val elRange = element.textRange ?: return EMPTY_TOKENIZER + + fun overlaps(list: List) = list.any { it.intersects(elRange) } + + // 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 + + // Identifiers: only if range is within identifiers index and not delegated to Grazie + if (overlaps(index.identifiers) && !grazieIds) return IDENTIFIER_TOKENIZER + + // Comments: only if not delegated to Grazie and overlapping indexed comments + if (!preferGrazie && overlaps(index.comments)) return COMMENT_TEXT_TOKENIZER + + // Strings: only if not delegated to Grazie, literals checking enabled, and overlapping indexed strings + if (!preferGrazie && settings.spellCheckStringLiterals && overlaps(index.strings)) return STRING_WITH_PRINTF_EXCLUDES + + return EMPTY_TOKENIZER + } + + private object EMPTY_TOKENIZER : Tokenizer() { + override fun tokenize(element: PsiElement, consumer: TokenConsumer) {} + } + + private object IDENTIFIER_TOKENIZER : Tokenizer() { + private val splitter = PlainTextSplitter.getInstance() + override fun tokenize(element: PsiElement, consumer: TokenConsumer) { + val text = element.text + if (text.isNullOrEmpty()) return + consumer.consumeToken(element, text, false, 0, TextRange(0, text.length), splitter) + } + } + + private object COMMENT_TEXT_TOKENIZER : Tokenizer() { + private val splitter = PlainTextSplitter.getInstance() + override fun tokenize(element: PsiElement, consumer: TokenConsumer) { + val text = element.text + if (text.isNullOrEmpty()) return + consumer.consumeToken(element, text, false, 0, TextRange(0, text.length), splitter) + } + } + + private object STRING_WITH_PRINTF_EXCLUDES : Tokenizer() { + private val splitter = PlainTextSplitter.getInstance() + + // Regex for printf-style specifiers: %[flags][width][.precision][length]type + // This is intentionally permissive to skip common cases like %s, %d, %-12s, %08x, %.2f, %% + private val SPEC = Regex("%(?:[-+ #0]*(?:\\d+)?(?:\\.\\d+)?[a-zA-Z%])") + + override fun tokenize(element: PsiElement, consumer: TokenConsumer) { + // Check project settings whether literals should be spell-checked + val settings = LyngFormatterSettings.getInstance(element.project) + if (!settings.spellCheckStringLiterals) return + + val text = element.text + if (text.isEmpty()) return + + // Try to strip surrounding quotes (simple lexer token for Lyng strings) + var startOffsetInElement = 0 + var endOffsetInElement = text.length + if (text.length >= 2 && (text.first() == '"' && text.last() == '"' || text.first() == '\'' && text.last() == '\'')) { + startOffsetInElement = 1 + endOffsetInElement = text.length - 1 + } + if (endOffsetInElement <= startOffsetInElement) return + + val content = text.substring(startOffsetInElement, endOffsetInElement) + + var last = 0 + for (m in SPEC.findAll(content)) { + val ms = m.range.first + val me = m.range.last + 1 + if (ms > last) { + val range = TextRange(startOffsetInElement + last, startOffsetInElement + ms) + consumer.consumeToken(element, text, false, 0, range, splitter) + } + last = me + } + if (last < content.length) { + val range = TextRange(startOffsetInElement + last, startOffsetInElement + content.length) + consumer.consumeToken(element, text, false, 0, range, splitter) + } + } + } +} diff --git a/lyng-idea/src/main/resources/META-INF/grazie-bundled.xml b/lyng-idea/src/main/resources/META-INF/grazie-bundled.xml new file mode 100644 index 0000000..774b605 --- /dev/null +++ b/lyng-idea/src/main/resources/META-INF/grazie-bundled.xml @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/lyng-idea/src/main/resources/META-INF/grazie-lite.xml b/lyng-idea/src/main/resources/META-INF/grazie-lite.xml new file mode 100644 index 0000000..f38993f --- /dev/null +++ b/lyng-idea/src/main/resources/META-INF/grazie-lite.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/lyng-idea/src/main/resources/META-INF/grazie.xml b/lyng-idea/src/main/resources/META-INF/grazie.xml new file mode 100644 index 0000000..02401c7 --- /dev/null +++ b/lyng-idea/src/main/resources/META-INF/grazie.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/lyng-idea/src/main/resources/META-INF/plugin.xml b/lyng-idea/src/main/resources/META-INF/plugin.xml index 3dcdc71..487c4f1 100644 --- a/lyng-idea/src/main/resources/META-INF/plugin.xml +++ b/lyng-idea/src/main/resources/META-INF/plugin.xml @@ -33,6 +33,12 @@ com.intellij.modules.platform com.intellij.modules.lang + + com.intellij.spellchecker + + tanvd.grazi + + com.intellij.grazie @@ -50,6 +56,9 @@ + + + @@ -80,6 +89,7 @@ + diff --git a/lyng-idea/src/main/resources/META-INF/spellchecker.xml b/lyng-idea/src/main/resources/META-INF/spellchecker.xml new file mode 100644 index 0000000..293f6a8 --- /dev/null +++ b/lyng-idea/src/main/resources/META-INF/spellchecker.xml @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/lyng-idea/src/main/resources/dictionaries/en-basic.txt b/lyng-idea/src/main/resources/dictionaries/en-basic.txt new file mode 100644 index 0000000..cb80058 --- /dev/null +++ b/lyng-idea/src/main/resources/dictionaries/en-basic.txt @@ -0,0 +1,466 @@ +the +be +to +of +and +a +in +that +have +I +it +for +not +on +with +he +as +you +do +at +this +but +his +by +from +they +we +say +her +she +or +an +will +my +one +all +would +there +their +what +so +up +out +if +about +who +get +which +go +me +when +make +can +like +time +no +just +him +know +take +people +into +year +your +good +some +could +them +see +other +than +then +now +look +only +come +its +over +think +also +back +after +use +two +how +our +work +first +well +way +even +new +want +because +any +these +give +day +most +us +is +are +was +were +been +being +does +did +done +has +had +having +may +might +must +shall +should +ought +need +used +here +therefore +where +why +while +until +since +before +afterward +between +among +without +within +through +across +against +toward +upon +above +below +under +around +near +far +early +late +often +always +never +seldom +sometimes +usually +really +very +quite +rather +almost +already +again +still +yet +soon +today +tomorrow +yesterday +number +string +boolean +true +false +null +none +file +files +path +paths +line +lines +word +words +count +value +values +name +names +title +text +message +error +errors +warning +warnings +info +information +debug +trace +format +printf +specifier +specifiers +pattern +patterns +match +matches +regex +version +versions +module +modules +package +packages +import +imports +export +exports +class +classes +object +objects +function +functions +method +methods +parameter +parameters +argument +arguments +variable +variables +constant +constants +type +types +generic +generics +map +maps +list +lists +array +arrays +set +sets +queue +stack +graph +tree +node +nodes +edge +edges +pair +pairs +key +keys +value +values +index +indices +length +size +empty +contains +equals +compare +greater +less +minimum +maximum +average +sum +total +random +round +floor +ceil +sin +cos +tan +sqrt +abs +min +max +read +write +open +close +append +create +delete +remove +update +save +load +start +stop +run +execute +return +break +continue +try +catch +finally +throw +throws +if +else +when +while +for +loop +range +case +switch +default +optional +required +enable +disable +enabled +disabled +visible +hidden +public +private +protected +internal +external +inline +override +abstract +sealed +open +final +static +const +lazy +late +init +initialize +configuration +settings +option +options +preference +preferences +project +projects +module +modules +build +builds +compile +compiles +compiler +test +tests +testing +assert +assertion +result +results +success +failure +status +state +context +scope +scopes +token +tokens +identifier +identifiers +keyword +keywords +comment +comments +string +strings +literal +literals +formatting +formatter +spell +spelling +dictionary +dictionaries +language +languages +natural +grazie +typo +typos +suggest +suggestion +suggestions +replace +replacement +replacements +learn +learned +learns +filter +filters +exclude +excludes +include +includes +bundle +bundled +resource +resources +gzipped +plain +text +editor +editors +inspection +inspections +highlight +highlighting +underline +underlines +style +styles +range +ranges +offset +offsets +position +positions +apply +applies +provides +present +absent +available +unavailable +version +build +platform +ide +intellij +plugin +plugins +sandbox +gradle +kotlin +java +linux +macos +windows +unix +system +systems +support +supports +compatible +compatibility +fallback +native +automatic +autoswitch +switch +switches \ No newline at end of file diff --git a/lyng-idea/src/main/resources/dictionaries/tech-lyng.txt b/lyng-idea/src/main/resources/dictionaries/tech-lyng.txt new file mode 100644 index 0000000..f55a4fc --- /dev/null +++ b/lyng-idea/src/main/resources/dictionaries/tech-lyng.txt @@ -0,0 +1,282 @@ +# Lyng/tech vocabulary – one word per line, lowercase +lyng +miniast +binder +printf +specifier +specifiers +regex +regexp +token +tokens +lexer +parser +syntax +semantic +highlight +highlighting +underline +typo +typos +dictionary +dictionaries +grazie +natural +languages +inspection +inspections +annotation +annotator +annotations +quickfix +quickfixes +intention +intentions +replacement +replacements +identifier +identifiers +keyword +keywords +comment +comments +string +strings +literal +literals +formatting +formatter +splitter +camelcase +snakecase +pascalcase +uppercase +lowercase +titlecase +case +cases +project +module +modules +resource +resources +bundle +bundled +gzipped +plaintext +text +range +ranges +offset +offsets +position +positions +apply +applies +runtime +compile +build +artifact +artifacts +plugin +plugins +intellij +idea +sandbox +gradle +kotlin +java +jvm +coroutines +suspend +scope +scopes +context +contexts +tokenizer +tokenizers +spell +spelling +spellcheck +spellchecker +fallback +native +autoswitch +switch +switching +enable +disable +enabled +disabled +setting +settings +preference +preferences +editor +filetype +filetypes +language +languages +psi +psielement +psifile +textcontent +textdomain +stealth +stealthy +printfspec +format +formats +pattern +patterns +match +matches +group +groups +node +nodes +tree +graph +edge +edges +pair +pairs +map +maps +list +lists +array +arrays +set +sets +queue +stack +index +indices +length +size +empty +contains +equals +compare +greater +less +minimum +maximum +average +sum +total +random +round +floor +ceil +sin +cos +tan +sqrt +abs +min +max +read +write +open +close +append +create +delete +remove +update +save +load +start +stop +run +execute +return +break +continue +try +catch +finally +throw +throws +if +else +when +while +for +loop +rangeop +caseop +switchop +default +optional +required +public +private +protected +internal +external +inline +override +abstract +sealed +open +final +static +const +lazy +late +init +initialize +configuration +option +options +projectwide +workspace +crossplatform +multiplatform +commonmain +jsmain +native +platform +api +implementation +dependency +dependencies +classpath +source +sources +document +documents +logging +logger +info +debug +trace +warning +error +severity +severitylevel +intentionaction +daemon +daemoncodeanalyzer +restart +textattributes +textattributeskey +typostyle +learned +learn +tech +vocabulary +domain +term +terms +us +uk +american +british +colour +color +organisation +organization \ No newline at end of file