Compare commits

..

2 Commits

Author SHA1 Message Date
b7fe04d65f cosmetics 2026-01-14 22:11:33 +03:00
ec28b219f3 plugin: another attempt to fix spell checker 2026-01-14 14:28:50 +03:00
15 changed files with 119 additions and 646 deletions

View File

@ -44,10 +44,7 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
data class Span(val start: Int, val end: Int, val key: com.intellij.openapi.editor.colors.TextAttributesKey)
data class Error(val start: Int, val end: Int, val message: String)
data class Result(val modStamp: Long, val spans: List<Span>, val error: Error? = null,
val spellIdentifiers: List<IntRange> = emptyList(),
val spellComments: List<IntRange> = emptyList(),
val spellStrings: List<IntRange> = emptyList())
data class Result(val modStamp: Long, val spans: List<Span>, val error: Error? = null)
override fun collectInformation(file: PsiFile): Input? {
val doc: Document = file.viewProvider.document ?: return null
@ -318,62 +315,6 @@ 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 ->
if (s.kind == HighlightKind.EnumConstant) {
val start = s.range.start
@ -384,40 +325,9 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
}
}
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)
return Result(collectedInfo.modStamp, out, null)
}
/**
* 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) {
if (annotationResult == null) return
@ -429,32 +339,6 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
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) {
holder.newSilentAnnotation(HighlightSeverity.INFORMATION)
.range(TextRange(s.start, s.end))

View File

@ -1,43 +0,0 @@
/*
* 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)
}
}

View File

@ -1,137 +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.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)
}
}

View File

@ -19,84 +19,29 @@ 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
import net.sergeych.lyng.idea.psi.LyngElementTypes
/**
* 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.
* Simplified TextExtractor for Lyng.
* Designates areas for Natural Languages (Grazie) to check.
*/
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
// 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) {
val domain = when (type) {
LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT -> TextDomain.COMMENTS
else -> null
LyngTokenTypes.STRING -> TextDomain.LITERALS
LyngElementTypes.NAME_IDENTIFIER,
LyngElementTypes.PARAMETER_NAME,
LyngElementTypes.ENUM_CONSTANT_NAME -> TextDomain.COMMENTS
else -> return 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
}
if (!allowedDomains.contains(domain)) return null
return TextContent.psiFragment(domain, element)
}
}

View File

@ -1,86 +0,0 @@
/*
* 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
}
}
}

View File

@ -0,0 +1,27 @@
/*
* 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)
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -45,7 +45,68 @@ class LyngParserDefinition : ParserDefinition {
override fun createParser(project: Project?): PsiParser = PsiParser { root, builder ->
val mark: PsiBuilder.Marker = builder.mark()
while (!builder.eof()) builder.advanceLexer()
var lastKeyword: String? = null
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)
builder.treeBuilt
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -32,24 +32,6 @@ class LyngFormatterSettings(private val project: Project) : PersistentStateCompo
var reindentClosedBlockOnEnter: Boolean = true,
var reindentPastedBlocks: Boolean = true,
var normalizeBlockCommentIndent: Boolean = false,
var spellCheckStringLiterals: Boolean = true,
// When Grazie/Natural Languages is present, prefer it for comments and literals (avoid legacy duplicates)
var preferGrazieForCommentsAndLiterals: Boolean = true,
// When Grazie is available, also check identifiers via Grazie.
// Default OFF because Grazie typically doesn't flag code identifiers; legacy Spellchecker is better for code.
var grazieChecksIdentifiers: Boolean = false,
// Grazie-only fallback: treat identifiers as comments domain so Grazie applies spelling rules
var grazieTreatIdentifiersAsComments: Boolean = true,
// Grazie-only fallback: treat string literals as comments domain when LITERALS domain is not requested
var grazieTreatLiteralsAsComments: Boolean = true,
// Debug helper: show the exact ranges we feed to Grazie/legacy as weak warnings
var debugShowSpellFeed: Boolean = false,
// Visuals: render Lyng typos using the standard Typo green underline styling
var showTyposWithGreenUnderline: Boolean = true,
// Enable lightweight quick-fixes (Replace..., Add to dictionary) without legacy Spell Checker
var offerLyngTypoQuickFixes: Boolean = true,
// Per-project learned words (do not flag again)
var learnedWords: MutableSet<String> = mutableSetOf(),
// Experimental: enable Lyng autocompletion (can be disabled if needed)
var enableLyngCompletionExperimental: Boolean = true,
)
@ -82,42 +64,6 @@ class LyngFormatterSettings(private val project: Project) : PersistentStateCompo
get() = myState.normalizeBlockCommentIndent
set(value) { myState.normalizeBlockCommentIndent = value }
var spellCheckStringLiterals: Boolean
get() = myState.spellCheckStringLiterals
set(value) { myState.spellCheckStringLiterals = value }
var preferGrazieForCommentsAndLiterals: Boolean
get() = myState.preferGrazieForCommentsAndLiterals
set(value) { myState.preferGrazieForCommentsAndLiterals = value }
var grazieChecksIdentifiers: Boolean
get() = myState.grazieChecksIdentifiers
set(value) { myState.grazieChecksIdentifiers = value }
var grazieTreatIdentifiersAsComments: Boolean
get() = myState.grazieTreatIdentifiersAsComments
set(value) { myState.grazieTreatIdentifiersAsComments = value }
var grazieTreatLiteralsAsComments: Boolean
get() = myState.grazieTreatLiteralsAsComments
set(value) { myState.grazieTreatLiteralsAsComments = value }
var debugShowSpellFeed: Boolean
get() = myState.debugShowSpellFeed
set(value) { myState.debugShowSpellFeed = value }
var showTyposWithGreenUnderline: Boolean
get() = myState.showTyposWithGreenUnderline
set(value) { myState.showTyposWithGreenUnderline = value }
var offerLyngTypoQuickFixes: Boolean
get() = myState.offerLyngTypoQuickFixes
set(value) { myState.offerLyngTypoQuickFixes = value }
var learnedWords: MutableSet<String>
get() = myState.learnedWords
set(value) { myState.learnedWords = value }
var enableLyngCompletionExperimental: Boolean
get() = myState.enableLyngCompletionExperimental
set(value) { myState.enableLyngCompletionExperimental = value }

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -30,14 +30,6 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur
private var reindentClosedBlockCb: JCheckBox? = null
private var reindentPasteCb: JCheckBox? = null
private var normalizeBlockCommentIndentCb: JCheckBox? = null
private var spellCheckLiteralsCb: JCheckBox? = null
private var preferGrazieCommentsLiteralsCb: JCheckBox? = null
private var grazieChecksIdentifiersCb: JCheckBox? = null
private var grazieIdsAsCommentsCb: JCheckBox? = null
private var grazieLiteralsAsCommentsCb: JCheckBox? = null
private var debugShowSpellFeedCb: JCheckBox? = null
private var showTyposGreenCb: JCheckBox? = null
private var offerQuickFixesCb: JCheckBox? = null
private var enableCompletionCb: JCheckBox? = null
override fun getDisplayName(): String = "Lyng Formatter"
@ -50,14 +42,6 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur
reindentClosedBlockCb = JCheckBox("Reindent enclosed block on Enter after '}'")
reindentPasteCb = JCheckBox("Reindent pasted blocks (align pasted code to current indent)")
normalizeBlockCommentIndentCb = JCheckBox("Normalize block comment indentation [experimental]")
spellCheckLiteralsCb = JCheckBox("Spell check string literals (skip % specifiers like %s, %d, %-12s)")
preferGrazieCommentsLiteralsCb = JCheckBox("Prefer Natural Languages/Grazie for comments and string literals (avoid duplicates)")
grazieChecksIdentifiersCb = JCheckBox("Check identifiers via Natural Languages/Grazie when available")
grazieIdsAsCommentsCb = JCheckBox("Natural Languages/Grazie: treat identifiers as comments (forces spelling checks in 2024.3)")
grazieLiteralsAsCommentsCb = JCheckBox("Natural Languages/Grazie: treat string literals as comments when literals are not processed")
debugShowSpellFeedCb = JCheckBox("Debug: show spell-feed ranges (weak warnings)")
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)")
// Tooltips / short help
@ -66,27 +50,12 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur
reindentClosedBlockCb?.toolTipText = "On Enter after a closing '}', reindent the just-closed {…} block using formatter rules."
reindentPasteCb?.toolTipText = "When caret is in leading whitespace, reindent the pasted text and align it to the caret's indent."
normalizeBlockCommentIndentCb?.toolTipText = "Experimental: normalize indentation inside /* … */ comments (code is not modified)."
preferGrazieCommentsLiteralsCb?.toolTipText = "When ON and Natural Languages/Grazie is installed, comments and string literals are checked by Grazie. Turn OFF to force legacy Spellchecker to check them."
grazieChecksIdentifiersCb?.toolTipText = "When ON and Natural Languages/Grazie is installed, identifiers (non-keywords) are checked by Grazie too."
grazieIdsAsCommentsCb?.toolTipText = "Grazie-only fallback: route identifiers as COMMENTS domain so Grazie applies spelling in 2024.3."
grazieLiteralsAsCommentsCb?.toolTipText = "Grazie-only fallback: when Grammar doesn't process literals, route strings as COMMENTS so they are checked."
debugShowSpellFeedCb?.toolTipText = "Show the exact ranges we feed to spellcheckers (ids/comments/strings) as weak warnings."
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)."
p.add(spacingCb)
p.add(wrappingCb)
p.add(reindentClosedBlockCb)
p.add(reindentPasteCb)
p.add(normalizeBlockCommentIndentCb)
p.add(spellCheckLiteralsCb)
p.add(preferGrazieCommentsLiteralsCb)
p.add(grazieChecksIdentifiersCb)
p.add(grazieIdsAsCommentsCb)
p.add(grazieLiteralsAsCommentsCb)
p.add(debugShowSpellFeedCb)
p.add(showTyposGreenCb)
p.add(offerQuickFixesCb)
p.add(enableCompletionCb)
panel = p
reset()
@ -100,14 +69,6 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur
reindentClosedBlockCb?.isSelected != s.reindentClosedBlockOnEnter ||
reindentPasteCb?.isSelected != s.reindentPastedBlocks ||
normalizeBlockCommentIndentCb?.isSelected != s.normalizeBlockCommentIndent ||
spellCheckLiteralsCb?.isSelected != s.spellCheckStringLiterals ||
preferGrazieCommentsLiteralsCb?.isSelected != s.preferGrazieForCommentsAndLiterals ||
grazieChecksIdentifiersCb?.isSelected != s.grazieChecksIdentifiers ||
grazieIdsAsCommentsCb?.isSelected != s.grazieTreatIdentifiersAsComments ||
grazieLiteralsAsCommentsCb?.isSelected != s.grazieTreatLiteralsAsComments ||
debugShowSpellFeedCb?.isSelected != s.debugShowSpellFeed ||
showTyposGreenCb?.isSelected != s.showTyposWithGreenUnderline ||
offerQuickFixesCb?.isSelected != s.offerLyngTypoQuickFixes ||
enableCompletionCb?.isSelected != s.enableLyngCompletionExperimental
}
@ -118,14 +79,6 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur
s.reindentClosedBlockOnEnter = reindentClosedBlockCb?.isSelected == true
s.reindentPastedBlocks = reindentPasteCb?.isSelected == true
s.normalizeBlockCommentIndent = normalizeBlockCommentIndentCb?.isSelected == true
s.spellCheckStringLiterals = spellCheckLiteralsCb?.isSelected == true
s.preferGrazieForCommentsAndLiterals = preferGrazieCommentsLiteralsCb?.isSelected == true
s.grazieChecksIdentifiers = grazieChecksIdentifiersCb?.isSelected == true
s.grazieTreatIdentifiersAsComments = grazieIdsAsCommentsCb?.isSelected == true
s.grazieTreatLiteralsAsComments = grazieLiteralsAsCommentsCb?.isSelected == true
s.debugShowSpellFeed = debugShowSpellFeedCb?.isSelected == true
s.showTyposWithGreenUnderline = showTyposGreenCb?.isSelected == true
s.offerLyngTypoQuickFixes = offerQuickFixesCb?.isSelected == true
s.enableLyngCompletionExperimental = enableCompletionCb?.isSelected == true
}
@ -136,14 +89,6 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur
reindentClosedBlockCb?.isSelected = s.reindentClosedBlockOnEnter
reindentPasteCb?.isSelected = s.reindentPastedBlocks
normalizeBlockCommentIndentCb?.isSelected = s.normalizeBlockCommentIndent
spellCheckLiteralsCb?.isSelected = s.spellCheckStringLiterals
preferGrazieCommentsLiteralsCb?.isSelected = s.preferGrazieForCommentsAndLiterals
grazieChecksIdentifiersCb?.isSelected = s.grazieChecksIdentifiers
grazieIdsAsCommentsCb?.isSelected = s.grazieTreatIdentifiersAsComments
grazieLiteralsAsCommentsCb?.isSelected = s.grazieTreatLiteralsAsComments
debugShowSpellFeedCb?.isSelected = s.debugShowSpellFeed
showTyposGreenCb?.isSelected = s.showTyposWithGreenUnderline
offerQuickFixesCb?.isSelected = s.offerLyngTypoQuickFixes
enableCompletionCb?.isSelected = s.enableLyngCompletionExperimental
}
}

View File

@ -1,50 +0,0 @@
/*
* 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}")
}
}

View File

@ -20,10 +20,11 @@ import com.intellij.psi.PsiElement
import com.intellij.spellchecker.tokenizer.SpellcheckingStrategy
import com.intellij.spellchecker.tokenizer.Tokenizer
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
import net.sergeych.lyng.idea.psi.LyngElementTypes
/**
* Standard IntelliJ spellchecking strategy for Lyng.
* It uses the MiniAst-driven [LyngSpellIndex] to limit identifier checks to declarations only.
* Uses the simplified PSI structure to identify declarations.
*/
class LyngSpellcheckingStrategy : SpellcheckingStrategy() {
override fun getTokenizer(element: PsiElement?): Tokenizer<*> {
@ -31,21 +32,9 @@ class LyngSpellcheckingStrategy : SpellcheckingStrategy() {
return when (type) {
LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT -> TEXT_TOKENIZER
LyngTokenTypes.STRING -> TEXT_TOKENIZER
LyngTokenTypes.IDENTIFIER -> {
// We use standard NameIdentifierOwner/PsiNamedElement-based logic
// 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
}
LyngElementTypes.NAME_IDENTIFIER,
LyngElementTypes.PARAMETER_NAME,
LyngElementTypes.ENUM_CONSTANT_NAME -> TEXT_TOKENIZER
else -> super.getTokenizer(element)
}
}

View File

@ -1,5 +1,5 @@
<!--
~ Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
~ Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
@ -20,8 +20,6 @@
-->
<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"/>

View File

@ -1,5 +1,5 @@
<!--
~ Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
~ Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
@ -21,8 +21,6 @@
-->
<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"/>

View File

@ -112,7 +112,7 @@ open class ObjException(
)
} else {
// 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
}

View File

@ -266,12 +266,8 @@ class StackTraceEntry(
/* Print this exception and its stack trace to standard output. */
fun Exception.printStackTrace() {
println(this)
var lastEntry = null
for( entry in stackTrace ) {
if( lastEntry == null || lastEntry !is StackTraceEntry || lastEntry.line != entry.line )
for( entry in stackTrace )
println("\tat "+entry.toString())
lastEntry = entry
}
}
/* Compile this string into a regular expression. */