Add Grazie-backed grammar checker and dictionary support to Lyng IDEA plugin and variants for replacements with or without Grazie interfaces
This commit is contained in:
parent
fbea13570e
commit
2e17297355
@ -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.
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@ -47,7 +47,10 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
|
||||
|
||||
data class Span(val start: Int, val end: Int, val key: com.intellij.openapi.editor.colors.TextAttributesKey)
|
||||
data class Error(val start: Int, val end: Int, val message: String)
|
||||
data class Result(val modStamp: Long, val spans: List<Span>, val error: Error? = null)
|
||||
data class Result(val modStamp: Long, val spans: List<Span>, val error: Error? = null,
|
||||
val spellIdentifiers: List<IntRange> = emptyList(),
|
||||
val spellComments: List<IntRange> = emptyList(),
|
||||
val spellStrings: List<IntRange> = emptyList())
|
||||
|
||||
override fun collectInformation(file: PsiFile): Input? {
|
||||
val doc: Document = file.viewProvider.document ?: return null
|
||||
@ -240,7 +243,29 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
|
||||
if (e is com.intellij.openapi.progress.ProcessCanceledException) throw e
|
||||
}
|
||||
|
||||
return Result(collectedInfo.modStamp, out, null)
|
||||
// Build spell index payload: identifiers from symbols + references; comments/strings from simple highlighter
|
||||
val idRanges = mutableSetOf<IntRange>()
|
||||
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<LyngExternalAnnotator.Input, Lyn
|
||||
val result = if (cached != null && currentStamp != null && cached.modStamp == currentStamp) cached else annotationResult
|
||||
file.putUserData(CACHE_KEY, result)
|
||||
|
||||
// Store spell index for spell/grammar engines to consume (suspend until ready)
|
||||
val ids = result.spellIdentifiers.map { TextRange(it.first, it.last + 1) }
|
||||
val coms = result.spellComments.map { TextRange(it.first, it.last + 1) }
|
||||
val strs = result.spellStrings.map { TextRange(it.first, it.last + 1) }
|
||||
net.sergeych.lyng.idea.spell.LyngSpellIndex.store(file,
|
||||
net.sergeych.lyng.idea.spell.LyngSpellIndex.Data(
|
||||
modStamp = result.modStamp,
|
||||
identifiers = ids,
|
||||
comments = coms,
|
||||
strings = strs
|
||||
)
|
||||
)
|
||||
|
||||
// Optional diagnostic overlay: visualize the ranges we will feed to spellcheckers
|
||||
val settings = net.sergeych.lyng.idea.settings.LyngFormatterSettings.getInstance(file.project)
|
||||
if (settings.debugShowSpellFeed) {
|
||||
fun paint(r: TextRange, label: String) {
|
||||
holder.newAnnotation(HighlightSeverity.WEAK_WARNING, "spell-feed: $label")
|
||||
.range(r)
|
||||
.create()
|
||||
}
|
||||
ids.forEach { paint(it, "id") }
|
||||
coms.forEach { paint(it, "comment") }
|
||||
if (settings.spellCheckStringLiterals) strs.forEach { paint(it, "string") }
|
||||
}
|
||||
|
||||
for (s in result.spans) {
|
||||
holder.newSilentAnnotation(HighlightSeverity.INFORMATION)
|
||||
.range(TextRange(s.start, s.end))
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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.editor.Editor
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.psi.PsiFile
|
||||
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
|
||||
|
||||
/**
|
||||
* Lightweight quick-fix that adds a word to the per-project Lyng dictionary.
|
||||
*/
|
||||
class AddToLyngDictionaryFix(private val word: String) : IntentionAction {
|
||||
override fun getText(): String = "Add '$word' to Lyng dictionary"
|
||||
override fun getFamilyName(): String = "Lyng Spelling"
|
||||
override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean = word.isNotBlank()
|
||||
override fun startInWriteAction(): Boolean = true
|
||||
|
||||
override fun invoke(project: Project, editor: Editor?, file: PsiFile?) {
|
||||
val settings = LyngFormatterSettings.getInstance(project)
|
||||
val learned = settings.learnedWords
|
||||
learned.add(word.lowercase())
|
||||
settings.learnedWords = learned
|
||||
// Restart daemon to refresh highlights
|
||||
if (file != null) DaemonCodeAnalyzer.getInstance(project).restart(file)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Very simple English dictionary loader for offline suggestions on IC-243.
|
||||
* It loads a word list from classpath resources. Supports plain text (one word per line)
|
||||
* and gzipped text if the resource ends with .gz.
|
||||
*/
|
||||
object EnglishDictionary {
|
||||
private val log = Logger.getInstance(EnglishDictionary::class.java)
|
||||
|
||||
@Volatile private var loaded = false
|
||||
@Volatile private var words: Set<String> = 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<String>(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<String> {
|
||||
ensureLoaded()
|
||||
return words
|
||||
}
|
||||
}
|
||||
@ -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<LyngGrazieAnnotator.Input, LyngGrazieAnnotator.Result>(), 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<Finding>)
|
||||
|
||||
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<Pair<TextContent, TextRange>>()
|
||||
try {
|
||||
fun addFragments(ranges: List<TextRange>, 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<Finding>()
|
||||
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 ?: "<none>"}, 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<Collection<Any>?, 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()) "<none>" 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<Any>
|
||||
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<Any>
|
||||
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<Any>
|
||||
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<Any>
|
||||
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<Any>
|
||||
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<Any>(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<Any>
|
||||
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<Any>(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<Any>
|
||||
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<Long> = 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<Pair<TextContent, TextRange>>,
|
||||
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<Pair<TextContent, TextRange>>,
|
||||
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<String> {
|
||||
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<String>()
|
||||
all.addAll(fromProject)
|
||||
all.addAll(fromTech)
|
||||
all.addAll(fromEnglish)
|
||||
data class Cand(val w: String, val d: Int, val p: Int)
|
||||
val cands = ArrayList<Cand>(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<Cand> { 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<String> {
|
||||
// Simple approach: use current file text; can be extended to project scanning later
|
||||
val text = file.viewProvider.document?.text ?: return emptySet()
|
||||
val out = LinkedHashSet<String>()
|
||||
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<String> {
|
||||
// Split on underscores and camelCase boundaries
|
||||
val unders = token.split('_').filter { it.isNotBlank() }
|
||||
val out = mutableListOf<String>()
|
||||
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]
|
||||
}
|
||||
}
|
||||
@ -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<String> = 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<TextRange>): 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<IntRange> {
|
||||
val result = LinkedHashSet<IntRange>()
|
||||
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<Int, Int> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -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<String> = java.util.Collections.synchronizedSet(mutableSetOf())
|
||||
|
||||
override fun buildTextContent(element: PsiElement, allowedDomains: Set<TextDomain>): 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<com.intellij.openapi.util.TextRange>): 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String> = 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<String>(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<String> {
|
||||
ensureLoaded()
|
||||
return words
|
||||
}
|
||||
}
|
||||
@ -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<String> = 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<String>
|
||||
get() = myState.learnedWords
|
||||
set(value) { myState.learnedWords = value }
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun getInstance(project: Project): LyngFormatterSettings = project.getService(LyngFormatterSettings::class.java)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<TextRange>,
|
||||
val comments: List<TextRange>,
|
||||
val strings: List<TextRange>,
|
||||
)
|
||||
|
||||
private val KEY: Key<Data> = 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}")
|
||||
}
|
||||
}
|
||||
@ -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<TextRange>) = 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<PsiElement>() {
|
||||
override fun tokenize(element: PsiElement, consumer: TokenConsumer) {}
|
||||
}
|
||||
|
||||
private object IDENTIFIER_TOKENIZER : Tokenizer<PsiElement>() {
|
||||
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<PsiElement>() {
|
||||
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<PsiElement>() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
lyng-idea/src/main/resources/META-INF/grazie-bundled.xml
Normal file
29
lyng-idea/src/main/resources/META-INF/grazie-bundled.xml
Normal file
@ -0,0 +1,29 @@
|
||||
<!--
|
||||
~ 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 (bundled Natural Languages) optional descriptor for Lyng. Loaded when plugin ID com.intellij.grazie is present.
|
||||
-->
|
||||
<idea-plugin>
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<grazie.grammar.strategy language="Lyng"
|
||||
implementationClass="net.sergeych.lyng.idea.grazie.LyngGrazieStrategy"/>
|
||||
<!-- Provide text extraction for Lyng PSI so Grazie (bundled Natural Languages) can check content -->
|
||||
<grazie.textExtractor language="Lyng"
|
||||
implementationClass="net.sergeych.lyng.idea.grazie.LyngTextExtractor"/>
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
30
lyng-idea/src/main/resources/META-INF/grazie-lite.xml
Normal file
30
lyng-idea/src/main/resources/META-INF/grazie-lite.xml
Normal file
@ -0,0 +1,30 @@
|
||||
<!--
|
||||
~ 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 Lite/Pro optional descriptor for Lyng. Loaded when plugin ID tanvd.grazi is present.
|
||||
It delegates to the same strategy class as the bundled Natural Languages.
|
||||
-->
|
||||
<idea-plugin>
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<grazie.grammar.strategy language="Lyng"
|
||||
implementationClass="net.sergeych.lyng.idea.grazie.LyngGrazieStrategy"/>
|
||||
<!-- Provide text extraction for Lyng PSI so Grazie can actually check content -->
|
||||
<grazie.textExtractor language="Lyng"
|
||||
implementationClass="net.sergeych.lyng.idea.grazie.LyngTextExtractor"/>
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
28
lyng-idea/src/main/resources/META-INF/grazie.xml
Normal file
28
lyng-idea/src/main/resources/META-INF/grazie.xml
Normal file
@ -0,0 +1,28 @@
|
||||
<!--
|
||||
~ 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 (Lite/Pro/bundled) grammar checker extensions for Lyng. Loaded only when
|
||||
the Grazie plugin is present. plugin.xml declares optional dependency with this config file.
|
||||
-->
|
||||
<idea-plugin>
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<!-- Register Lyng strategy for Grazie (Natural Languages). -->
|
||||
<grazie.grammar.strategy language="Lyng"
|
||||
implementationClass="net.sergeych.lyng.idea.grazie.LyngGrazieStrategy"/>
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
@ -33,6 +33,12 @@
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
<!-- Needed for editor language features (syntax highlighting, etc.) -->
|
||||
<depends>com.intellij.modules.lang</depends>
|
||||
<!-- Spellchecker support (optional). If present, load spellchecker.xml which registers our strategy. -->
|
||||
<depends optional="true" config-file="spellchecker.xml">com.intellij.spellchecker</depends>
|
||||
<!-- Grazie (Lite/Pro) grammar checker support (optional). If present, load grazie-lite.xml -->
|
||||
<depends optional="true" config-file="grazie-lite.xml">tanvd.grazi</depends>
|
||||
<!-- Some IDE builds may expose Grazie/Natural Languages under another ID; load grazie-bundled.xml -->
|
||||
<depends optional="true" config-file="grazie-bundled.xml">com.intellij.grazie</depends>
|
||||
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<!-- Language and file type -->
|
||||
@ -50,6 +56,9 @@
|
||||
<!-- External annotator for semantic highlighting -->
|
||||
<externalAnnotator language="Lyng" implementationClass="net.sergeych.lyng.idea.annotators.LyngExternalAnnotator"/>
|
||||
|
||||
<!-- Grazie-backed spell/grammar annotator (runs only when Grazie is installed) -->
|
||||
<externalAnnotator language="Lyng" implementationClass="net.sergeych.lyng.idea.grazie.LyngGrazieAnnotator"/>
|
||||
|
||||
<!-- Quick documentation provider bound to Lyng language -->
|
||||
<lang.documentationProvider language="Lyng" implementationClass="net.sergeych.lyng.idea.docs.LyngDocumentationProvider"/>
|
||||
|
||||
@ -80,6 +89,7 @@
|
||||
|
||||
<!-- If targeting SDKs with stable RawText API, the EP below can be enabled instead: -->
|
||||
<!-- <copyPastePreProcessor implementation="net.sergeych.lyng.idea.editor.LyngCopyPastePreProcessor"/> -->
|
||||
|
||||
</extensions>
|
||||
|
||||
<actions/>
|
||||
|
||||
29
lyng-idea/src/main/resources/META-INF/spellchecker.xml
Normal file
29
lyng-idea/src/main/resources/META-INF/spellchecker.xml
Normal file
@ -0,0 +1,29 @@
|
||||
<!--
|
||||
~ 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.
|
||||
~
|
||||
-->
|
||||
|
||||
<!--
|
||||
Spellchecker extensions for Lyng are registered here and loaded only when
|
||||
the com.intellij.spellchecker plugin is available. The dependency is marked
|
||||
optional in plugin.xml with config-file="spellchecker.xml".
|
||||
-->
|
||||
<idea-plugin>
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<!-- Spellchecker strategy: identifiers + comments; literals configurable, skipping printf-like specs -->
|
||||
<spellchecker.support language="Lyng"
|
||||
implementationClass="net.sergeych.lyng.idea.spell.LyngSpellcheckingStrategy"/>
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
466
lyng-idea/src/main/resources/dictionaries/en-basic.txt
Normal file
466
lyng-idea/src/main/resources/dictionaries/en-basic.txt
Normal file
@ -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
|
||||
282
lyng-idea/src/main/resources/dictionaries/tech-lyng.txt
Normal file
282
lyng-idea/src/main/resources/dictionaries/tech-lyng.txt
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user