Compare commits
No commits in common. "b7fe04d65f6494517c17464bfd173e0524a065e1" and "6fa57c81979f3952d5cca9197c3de5254886c1f0" have entirely different histories.
b7fe04d65f
...
6fa57c8197
@ -44,7 +44,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
|
||||||
@ -315,6 +318,62 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build spell index payload: identifiers + comments/strings from simple highlighter.
|
||||||
|
// We limit identifier checks to declarations (val, var, fun, class, enum) and enum constants.
|
||||||
|
val spellIds = ArrayList<IntRange>()
|
||||||
|
fun addSpellId(pos: net.sergeych.lyng.Pos, name: String) {
|
||||||
|
if (pos.source == source) {
|
||||||
|
val s = source.offsetOf(pos)
|
||||||
|
val e = (s + name.length).coerceAtMost(text.length)
|
||||||
|
if (s < e) spellIds.add(s until e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add declarations from MiniAst
|
||||||
|
mini.declarations.forEach { d ->
|
||||||
|
addSpellId(d.nameStart, d.name)
|
||||||
|
when (d) {
|
||||||
|
is MiniFunDecl -> {
|
||||||
|
d.params.forEach { addSpellId(it.nameStart, it.name) }
|
||||||
|
addTypeNames(d.returnType, ::addSpellId)
|
||||||
|
addTypeNames(d.receiver, ::addSpellId)
|
||||||
|
}
|
||||||
|
is MiniValDecl -> {
|
||||||
|
addTypeNames(d.type, ::addSpellId)
|
||||||
|
addTypeNames(d.receiver, ::addSpellId)
|
||||||
|
}
|
||||||
|
is MiniEnumDecl -> {
|
||||||
|
if (d.entries.size == d.entryPositions.size) {
|
||||||
|
for (i in d.entries.indices) {
|
||||||
|
addSpellId(d.entryPositions[i], d.entries[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MiniClassDecl -> {
|
||||||
|
d.ctorFields.forEach { addSpellId(it.nameStart, it.name) }
|
||||||
|
d.classFields.forEach { addSpellId(it.nameStart, it.name) }
|
||||||
|
d.members.forEach { m ->
|
||||||
|
when (m) {
|
||||||
|
is MiniMemberFunDecl -> {
|
||||||
|
addSpellId(m.nameStart, m.name)
|
||||||
|
m.params.forEach { addSpellId(it.nameStart, it.name) }
|
||||||
|
addTypeNames(m.returnType, ::addSpellId)
|
||||||
|
}
|
||||||
|
is MiniMemberValDecl -> {
|
||||||
|
addSpellId(m.nameStart, m.name)
|
||||||
|
addTypeNames(m.type, ::addSpellId)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map Enum constants from token highlighter for highlighting only.
|
||||||
|
// We do NOT add them to spellIds here because they might be usages,
|
||||||
|
// and declarations are already handled via MiniEnumDecl above.
|
||||||
tokens.forEach { s ->
|
tokens.forEach { s ->
|
||||||
if (s.kind == HighlightKind.EnumConstant) {
|
if (s.kind == HighlightKind.EnumConstant) {
|
||||||
val start = s.range.start
|
val start = s.range.start
|
||||||
@ -325,9 +384,40 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result(collectedInfo.modStamp, out, null)
|
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 = spellIds,
|
||||||
|
spellComments = commentRanges,
|
||||||
|
spellStrings = stringRanges)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to add all segments of a type name to the spell index.
|
||||||
|
*/
|
||||||
|
private fun addTypeNames(t: MiniTypeRef?, add: (net.sergeych.lyng.Pos, String) -> Unit) {
|
||||||
|
when (t) {
|
||||||
|
is MiniTypeName -> t.segments.forEach { add(it.range.start, it.name) }
|
||||||
|
is MiniGenericType -> {
|
||||||
|
addTypeNames(t.base, add)
|
||||||
|
t.args.forEach { addTypeNames(it, add) }
|
||||||
|
}
|
||||||
|
|
||||||
|
is MiniFunctionType -> {
|
||||||
|
addTypeNames(t.receiver, add)
|
||||||
|
t.params.forEach { addTypeNames(it, add) }
|
||||||
|
addTypeNames(t.returnType, add)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MiniTypeVar -> {
|
||||||
|
// Type variables are declarations too
|
||||||
|
add(t.range.start, t.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun apply(file: PsiFile, annotationResult: Result?, holder: AnnotationHolder) {
|
override fun apply(file: PsiFile, annotationResult: Result?, holder: AnnotationHolder) {
|
||||||
if (annotationResult == null) return
|
if (annotationResult == null) return
|
||||||
@ -339,6 +429,32 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
|
|||||||
|
|
||||||
val doc = file.viewProvider.document
|
val doc = file.viewProvider.document
|
||||||
|
|
||||||
|
// 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,137 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* 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.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
|
||||||
|
|
||||||
|
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 && index.identifiers.any { it.contains(r) })
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,29 +19,84 @@ package net.sergeych.lyng.idea.grazie
|
|||||||
import com.intellij.grazie.text.TextContent
|
import com.intellij.grazie.text.TextContent
|
||||||
import com.intellij.grazie.text.TextContent.TextDomain
|
import com.intellij.grazie.text.TextContent.TextDomain
|
||||||
import com.intellij.grazie.text.TextExtractor
|
import com.intellij.grazie.text.TextExtractor
|
||||||
|
import com.intellij.openapi.diagnostic.Logger
|
||||||
import com.intellij.psi.PsiElement
|
import com.intellij.psi.PsiElement
|
||||||
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
|
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
|
||||||
import net.sergeych.lyng.idea.psi.LyngElementTypes
|
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
|
||||||
|
import net.sergeych.lyng.idea.spell.LyngSpellIndex
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simplified TextExtractor for Lyng.
|
* Provides Grazie with extractable text for Lyng PSI elements.
|
||||||
* Designates areas for Natural Languages (Grazie) to check.
|
* 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() {
|
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? {
|
override fun buildTextContent(element: PsiElement, allowedDomains: Set<TextDomain>): TextContent? {
|
||||||
val type = element.node?.elementType ?: return null
|
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
|
||||||
|
|
||||||
val domain = when (type) {
|
// Decide target domain by intersection with our MiniAst-driven index; prefer comments > strings > identifiers
|
||||||
|
var domain: TextDomain? = null
|
||||||
|
if (index != null && r != null) {
|
||||||
|
if (index.comments.any { it.intersects(r) }) domain = TextDomain.COMMENTS
|
||||||
|
else if (index.strings.any { it.intersects(r) } && settings.spellCheckStringLiterals) domain = TextDomain.LITERALS
|
||||||
|
else if (index.identifiers.any { it.contains(r) }) 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
|
LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT -> TextDomain.COMMENTS
|
||||||
LyngTokenTypes.STRING -> TextDomain.LITERALS
|
else -> null
|
||||||
LyngElementTypes.NAME_IDENTIFIER,
|
|
||||||
LyngElementTypes.PARAMETER_NAME,
|
|
||||||
LyngElementTypes.ENUM_CONSTANT_NAME -> TextDomain.COMMENTS
|
|
||||||
else -> return null
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (domain == null) return null
|
||||||
|
|
||||||
if (!allowedDomains.contains(domain)) 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) {
|
||||||
return TextContent.psiFragment(domain, element)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,27 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* 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.psi
|
|
||||||
|
|
||||||
import com.intellij.psi.tree.IElementType
|
|
||||||
import net.sergeych.lyng.idea.LyngLanguage
|
|
||||||
|
|
||||||
object LyngElementTypes {
|
|
||||||
val NAME_IDENTIFIER = IElementType("NAME_IDENTIFIER", LyngLanguage)
|
|
||||||
val PARAMETER_NAME = IElementType("PARAMETER_NAME", LyngLanguage)
|
|
||||||
val ENUM_CONSTANT_NAME = IElementType("ENUM_CONSTANT_NAME", LyngLanguage)
|
|
||||||
}
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
@ -45,68 +45,7 @@ class LyngParserDefinition : ParserDefinition {
|
|||||||
|
|
||||||
override fun createParser(project: Project?): PsiParser = PsiParser { root, builder ->
|
override fun createParser(project: Project?): PsiParser = PsiParser { root, builder ->
|
||||||
val mark: PsiBuilder.Marker = builder.mark()
|
val mark: PsiBuilder.Marker = builder.mark()
|
||||||
var lastKeyword: String? = null
|
while (!builder.eof()) builder.advanceLexer()
|
||||||
var inEnum = false
|
|
||||||
var inParams = false
|
|
||||||
var parenDepth = 0
|
|
||||||
var braceDepth = 0
|
|
||||||
|
|
||||||
while (!builder.eof()) {
|
|
||||||
val type = builder.tokenType
|
|
||||||
val text = builder.tokenText
|
|
||||||
|
|
||||||
when (type) {
|
|
||||||
LyngTokenTypes.KEYWORD -> {
|
|
||||||
lastKeyword = text
|
|
||||||
if (text == "enum") inEnum = true
|
|
||||||
}
|
|
||||||
LyngTokenTypes.PUNCT -> {
|
|
||||||
if (text == "(") {
|
|
||||||
parenDepth++
|
|
||||||
if (lastKeyword == "fun" || lastKeyword == "constructor" || lastKeyword == "init") inParams = true
|
|
||||||
} else if (text == ")") {
|
|
||||||
parenDepth--
|
|
||||||
if (parenDepth == 0) inParams = false
|
|
||||||
} else if (text == "{") {
|
|
||||||
braceDepth++
|
|
||||||
} else if (text == "}") {
|
|
||||||
braceDepth--
|
|
||||||
if (braceDepth == 0) inEnum = false
|
|
||||||
}
|
|
||||||
if (text != ".") lastKeyword = null
|
|
||||||
}
|
|
||||||
LyngTokenTypes.IDENTIFIER -> {
|
|
||||||
val m = builder.mark()
|
|
||||||
builder.advanceLexer()
|
|
||||||
val nextType = builder.tokenType
|
|
||||||
val isQualified = nextType == LyngTokenTypes.PUNCT && builder.tokenText == "."
|
|
||||||
|
|
||||||
if (!isQualified) {
|
|
||||||
when {
|
|
||||||
lastKeyword in setOf("fun", "val", "var", "class", "enum", "object", "interface", "type", "property") -> {
|
|
||||||
m.done(LyngElementTypes.NAME_IDENTIFIER)
|
|
||||||
}
|
|
||||||
inParams && parenDepth > 0 -> {
|
|
||||||
m.done(LyngElementTypes.PARAMETER_NAME)
|
|
||||||
}
|
|
||||||
inEnum && braceDepth > 0 && parenDepth == 0 -> {
|
|
||||||
m.done(LyngElementTypes.ENUM_CONSTANT_NAME)
|
|
||||||
}
|
|
||||||
else -> m.drop()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m.drop()
|
|
||||||
}
|
|
||||||
lastKeyword = null
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
LyngTokenTypes.WHITESPACE, LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT -> {
|
|
||||||
// keep lastKeyword
|
|
||||||
}
|
|
||||||
else -> lastKeyword = null
|
|
||||||
}
|
|
||||||
builder.advanceLexer()
|
|
||||||
}
|
|
||||||
mark.done(root)
|
mark.done(root)
|
||||||
builder.treeBuilt
|
builder.treeBuilt
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
@ -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(),
|
||||||
// Experimental: enable Lyng autocompletion (can be disabled if needed)
|
// Experimental: enable Lyng autocompletion (can be disabled if needed)
|
||||||
var enableLyngCompletionExperimental: Boolean = true,
|
var enableLyngCompletionExperimental: Boolean = true,
|
||||||
)
|
)
|
||||||
@ -64,6 +82,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 }
|
||||||
|
|
||||||
var enableLyngCompletionExperimental: Boolean
|
var enableLyngCompletionExperimental: Boolean
|
||||||
get() = myState.enableLyngCompletionExperimental
|
get() = myState.enableLyngCompletionExperimental
|
||||||
set(value) { myState.enableLyngCompletionExperimental = value }
|
set(value) { myState.enableLyngCompletionExperimental = value }
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
@ -30,6 +30,14 @@ 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
|
||||||
|
private var showTyposGreenCb: JCheckBox? = null
|
||||||
|
private var offerQuickFixesCb: JCheckBox? = null
|
||||||
private var enableCompletionCb: JCheckBox? = null
|
private var enableCompletionCb: JCheckBox? = null
|
||||||
|
|
||||||
override fun getDisplayName(): String = "Lyng Formatter"
|
override fun getDisplayName(): String = "Lyng Formatter"
|
||||||
@ -42,6 +50,14 @@ 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)")
|
||||||
|
showTyposGreenCb = JCheckBox("Show Lyng typos with green underline (TYPO styling)")
|
||||||
|
offerQuickFixesCb = JCheckBox("Offer Lyng typo quick fixes (Replace…, Add to dictionary) without Spell Checker")
|
||||||
enableCompletionCb = JCheckBox("Enable Lyng autocompletion (experimental)")
|
enableCompletionCb = JCheckBox("Enable Lyng autocompletion (experimental)")
|
||||||
|
|
||||||
// Tooltips / short help
|
// Tooltips / short help
|
||||||
@ -50,12 +66,27 @@ 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."
|
||||||
|
showTyposGreenCb?.toolTipText = "Render Lyng typos using the platform's green TYPO underline instead of generic warnings."
|
||||||
|
offerQuickFixesCb?.toolTipText = "Provide lightweight Replace… and Add to dictionary quick-fixes without requiring the legacy Spell Checker."
|
||||||
enableCompletionCb?.toolTipText = "Turn on/off the lightweight Lyng code completion (BASIC)."
|
enableCompletionCb?.toolTipText = "Turn on/off the lightweight Lyng code completion (BASIC)."
|
||||||
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)
|
||||||
|
p.add(showTyposGreenCb)
|
||||||
|
p.add(offerQuickFixesCb)
|
||||||
p.add(enableCompletionCb)
|
p.add(enableCompletionCb)
|
||||||
panel = p
|
panel = p
|
||||||
reset()
|
reset()
|
||||||
@ -69,6 +100,14 @@ 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 ||
|
||||||
|
showTyposGreenCb?.isSelected != s.showTyposWithGreenUnderline ||
|
||||||
|
offerQuickFixesCb?.isSelected != s.offerLyngTypoQuickFixes ||
|
||||||
enableCompletionCb?.isSelected != s.enableLyngCompletionExperimental
|
enableCompletionCb?.isSelected != s.enableLyngCompletionExperimental
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +118,14 @@ 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
|
||||||
|
s.showTyposWithGreenUnderline = showTyposGreenCb?.isSelected == true
|
||||||
|
s.offerLyngTypoQuickFixes = offerQuickFixesCb?.isSelected == true
|
||||||
s.enableLyngCompletionExperimental = enableCompletionCb?.isSelected == true
|
s.enableLyngCompletionExperimental = enableCompletionCb?.isSelected == true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,6 +136,14 @@ 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
|
||||||
|
showTyposGreenCb?.isSelected = s.showTyposWithGreenUnderline
|
||||||
|
offerQuickFixesCb?.isSelected = s.offerLyngTypoQuickFixes
|
||||||
enableCompletionCb?.isSelected = s.enableLyngCompletionExperimental
|
enableCompletionCb?.isSelected = s.enableLyngCompletionExperimental
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,11 +20,10 @@ import com.intellij.psi.PsiElement
|
|||||||
import com.intellij.spellchecker.tokenizer.SpellcheckingStrategy
|
import com.intellij.spellchecker.tokenizer.SpellcheckingStrategy
|
||||||
import com.intellij.spellchecker.tokenizer.Tokenizer
|
import com.intellij.spellchecker.tokenizer.Tokenizer
|
||||||
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
|
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
|
||||||
import net.sergeych.lyng.idea.psi.LyngElementTypes
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard IntelliJ spellchecking strategy for Lyng.
|
* Standard IntelliJ spellchecking strategy for Lyng.
|
||||||
* Uses the simplified PSI structure to identify declarations.
|
* It uses the MiniAst-driven [LyngSpellIndex] to limit identifier checks to declarations only.
|
||||||
*/
|
*/
|
||||||
class LyngSpellcheckingStrategy : SpellcheckingStrategy() {
|
class LyngSpellcheckingStrategy : SpellcheckingStrategy() {
|
||||||
override fun getTokenizer(element: PsiElement?): Tokenizer<*> {
|
override fun getTokenizer(element: PsiElement?): Tokenizer<*> {
|
||||||
@ -32,9 +31,21 @@ class LyngSpellcheckingStrategy : SpellcheckingStrategy() {
|
|||||||
return when (type) {
|
return when (type) {
|
||||||
LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT -> TEXT_TOKENIZER
|
LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT -> TEXT_TOKENIZER
|
||||||
LyngTokenTypes.STRING -> TEXT_TOKENIZER
|
LyngTokenTypes.STRING -> TEXT_TOKENIZER
|
||||||
LyngElementTypes.NAME_IDENTIFIER,
|
LyngTokenTypes.IDENTIFIER -> {
|
||||||
LyngElementTypes.PARAMETER_NAME,
|
// We use standard NameIdentifierOwner/PsiNamedElement-based logic
|
||||||
LyngElementTypes.ENUM_CONSTANT_NAME -> TEXT_TOKENIZER
|
// if it's a declaration. Argument names, class names, etc. are PSI-based.
|
||||||
|
// However, our PSI is currently very minimal (ASTWrapperPsiElement).
|
||||||
|
// So we stick to the index but ensure it is robustly filled.
|
||||||
|
val file = element.containingFile
|
||||||
|
val index = LyngSpellIndex.getUpToDate(file)
|
||||||
|
if (index != null) {
|
||||||
|
val range = element.textRange
|
||||||
|
if (index.identifiers.any { it.contains(range) }) {
|
||||||
|
return TEXT_TOKENIZER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EMPTY_TOKENIZER
|
||||||
|
}
|
||||||
else -> super.getTokenizer(element)
|
else -> super.getTokenizer(element)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<!--
|
<!--
|
||||||
~ Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
~ Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||||
~
|
~
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
~ you may not use this file except in compliance with the License.
|
~ you may not use this file except in compliance with the License.
|
||||||
@ -20,6 +20,8 @@
|
|||||||
-->
|
-->
|
||||||
<idea-plugin>
|
<idea-plugin>
|
||||||
<extensions defaultExtensionNs="com.intellij">
|
<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 -->
|
<!-- Provide text extraction for Lyng PSI so Grazie (bundled Natural Languages) can check content -->
|
||||||
<grazie.textExtractor language="Lyng"
|
<grazie.textExtractor language="Lyng"
|
||||||
implementationClass="net.sergeych.lyng.idea.grazie.LyngTextExtractor"/>
|
implementationClass="net.sergeych.lyng.idea.grazie.LyngTextExtractor"/>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<!--
|
<!--
|
||||||
~ Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
~ Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||||
~
|
~
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
~ you may not use this file except in compliance with the License.
|
~ you may not use this file except in compliance with the License.
|
||||||
@ -21,6 +21,8 @@
|
|||||||
-->
|
-->
|
||||||
<idea-plugin>
|
<idea-plugin>
|
||||||
<extensions defaultExtensionNs="com.intellij">
|
<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 -->
|
<!-- Provide text extraction for Lyng PSI so Grazie can actually check content -->
|
||||||
<grazie.textExtractor language="Lyng"
|
<grazie.textExtractor language="Lyng"
|
||||||
implementationClass="net.sergeych.lyng.idea.grazie.LyngTextExtractor"/>
|
implementationClass="net.sergeych.lyng.idea.grazie.LyngTextExtractor"/>
|
||||||
|
|||||||
@ -112,7 +112,7 @@ open class ObjException(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Fallback textual entry if StackTraceEntry class is not available in this scope
|
// Fallback textual entry if StackTraceEntry class is not available in this scope
|
||||||
result.list += ObjString("#${pos.source.objSourceName}:${pos.line+1}:${pos.column+1}: ${pos.currentLine}")
|
result.list += ObjString("?${pos.source.objSourceName}:${pos.line+1}:${pos.column+1}: ${pos.currentLine}")
|
||||||
}
|
}
|
||||||
lastPos = pos
|
lastPos = pos
|
||||||
}
|
}
|
||||||
|
|||||||
@ -266,8 +266,12 @@ class StackTraceEntry(
|
|||||||
/* Print this exception and its stack trace to standard output. */
|
/* Print this exception and its stack trace to standard output. */
|
||||||
fun Exception.printStackTrace() {
|
fun Exception.printStackTrace() {
|
||||||
println(this)
|
println(this)
|
||||||
for( entry in stackTrace )
|
var lastEntry = null
|
||||||
|
for( entry in stackTrace ) {
|
||||||
|
if( lastEntry == null || lastEntry !is StackTraceEntry || lastEntry.line != entry.line )
|
||||||
println("\tat "+entry.toString())
|
println("\tat "+entry.toString())
|
||||||
|
lastEntry = entry
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compile this string into a regular expression. */
|
/* Compile this string into a regular expression. */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user