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)
|
updateSinceUntilBuild.set(false)
|
||||||
// Include only available bundled plugins for this IDE build
|
// Include only available bundled plugins for this IDE build
|
||||||
plugins.set(listOf(
|
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 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 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? {
|
override fun collectInformation(file: PsiFile): Input? {
|
||||||
val doc: Document = file.viewProvider.document ?: return null
|
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
|
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) {
|
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
|
val result = if (cached != null && currentStamp != null && cached.modStamp == currentStamp) cached else annotationResult
|
||||||
file.putUserData(CACHE_KEY, result)
|
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) {
|
for (s in result.spans) {
|
||||||
holder.newSilentAnnotation(HighlightSeverity.INFORMATION)
|
holder.newSilentAnnotation(HighlightSeverity.INFORMATION)
|
||||||
.range(TextRange(s.start, s.end))
|
.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 reindentClosedBlockOnEnter: Boolean = true,
|
||||||
var reindentPastedBlocks: Boolean = true,
|
var reindentPastedBlocks: Boolean = true,
|
||||||
var normalizeBlockCommentIndent: Boolean = false,
|
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()
|
private var myState: State = State()
|
||||||
@ -62,6 +80,42 @@ class LyngFormatterSettings(private val project: Project) : PersistentStateCompo
|
|||||||
get() = myState.normalizeBlockCommentIndent
|
get() = myState.normalizeBlockCommentIndent
|
||||||
set(value) { myState.normalizeBlockCommentIndent = value }
|
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 {
|
companion object {
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getInstance(project: Project): LyngFormatterSettings = project.getService(LyngFormatterSettings::class.java)
|
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 reindentClosedBlockCb: JCheckBox? = null
|
||||||
private var reindentPasteCb: JCheckBox? = null
|
private var reindentPasteCb: JCheckBox? = null
|
||||||
private var normalizeBlockCommentIndentCb: 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"
|
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 '}'")
|
reindentClosedBlockCb = JCheckBox("Reindent enclosed block on Enter after '}'")
|
||||||
reindentPasteCb = JCheckBox("Reindent pasted blocks (align pasted code to current indent)")
|
reindentPasteCb = JCheckBox("Reindent pasted blocks (align pasted code to current indent)")
|
||||||
normalizeBlockCommentIndentCb = JCheckBox("Normalize block comment indentation [experimental]")
|
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
|
// Tooltips / short help
|
||||||
spacingCb?.toolTipText = "Applies minimal, safe spacing (e.g., around commas/operators, control-flow parens)."
|
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."
|
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."
|
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)."
|
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(spacingCb)
|
||||||
p.add(wrappingCb)
|
p.add(wrappingCb)
|
||||||
p.add(reindentClosedBlockCb)
|
p.add(reindentClosedBlockCb)
|
||||||
p.add(reindentPasteCb)
|
p.add(reindentPasteCb)
|
||||||
p.add(normalizeBlockCommentIndentCb)
|
p.add(normalizeBlockCommentIndentCb)
|
||||||
|
p.add(spellCheckLiteralsCb)
|
||||||
|
p.add(preferGrazieCommentsLiteralsCb)
|
||||||
|
p.add(grazieChecksIdentifiersCb)
|
||||||
|
p.add(grazieIdsAsCommentsCb)
|
||||||
|
p.add(grazieLiteralsAsCommentsCb)
|
||||||
|
p.add(debugShowSpellFeedCb)
|
||||||
panel = p
|
panel = p
|
||||||
reset()
|
reset()
|
||||||
return p
|
return p
|
||||||
@ -64,7 +87,13 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur
|
|||||||
wrappingCb?.isSelected != s.enableWrapping ||
|
wrappingCb?.isSelected != s.enableWrapping ||
|
||||||
reindentClosedBlockCb?.isSelected != s.reindentClosedBlockOnEnter ||
|
reindentClosedBlockCb?.isSelected != s.reindentClosedBlockOnEnter ||
|
||||||
reindentPasteCb?.isSelected != s.reindentPastedBlocks ||
|
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() {
|
override fun apply() {
|
||||||
@ -74,6 +103,12 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur
|
|||||||
s.reindentClosedBlockOnEnter = reindentClosedBlockCb?.isSelected == true
|
s.reindentClosedBlockOnEnter = reindentClosedBlockCb?.isSelected == true
|
||||||
s.reindentPastedBlocks = reindentPasteCb?.isSelected == true
|
s.reindentPastedBlocks = reindentPasteCb?.isSelected == true
|
||||||
s.normalizeBlockCommentIndent = normalizeBlockCommentIndentCb?.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() {
|
override fun reset() {
|
||||||
@ -83,5 +118,11 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur
|
|||||||
reindentClosedBlockCb?.isSelected = s.reindentClosedBlockOnEnter
|
reindentClosedBlockCb?.isSelected = s.reindentClosedBlockOnEnter
|
||||||
reindentPasteCb?.isSelected = s.reindentPastedBlocks
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
<depends>com.intellij.modules.platform</depends>
|
||||||
<!-- Needed for editor language features (syntax highlighting, etc.) -->
|
<!-- Needed for editor language features (syntax highlighting, etc.) -->
|
||||||
<depends>com.intellij.modules.lang</depends>
|
<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">
|
<extensions defaultExtensionNs="com.intellij">
|
||||||
<!-- Language and file type -->
|
<!-- Language and file type -->
|
||||||
@ -50,6 +56,9 @@
|
|||||||
<!-- External annotator for semantic highlighting -->
|
<!-- External annotator for semantic highlighting -->
|
||||||
<externalAnnotator language="Lyng" implementationClass="net.sergeych.lyng.idea.annotators.LyngExternalAnnotator"/>
|
<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 -->
|
<!-- Quick documentation provider bound to Lyng language -->
|
||||||
<lang.documentationProvider language="Lyng" implementationClass="net.sergeych.lyng.idea.docs.LyngDocumentationProvider"/>
|
<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: -->
|
<!-- If targeting SDKs with stable RawText API, the EP below can be enabled instead: -->
|
||||||
<!-- <copyPastePreProcessor implementation="net.sergeych.lyng.idea.editor.LyngCopyPastePreProcessor"/> -->
|
<!-- <copyPastePreProcessor implementation="net.sergeych.lyng.idea.editor.LyngCopyPastePreProcessor"/> -->
|
||||||
|
|
||||||
</extensions>
|
</extensions>
|
||||||
|
|
||||||
<actions/>
|
<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