From 6fa57c81979f3952d5cca9197c3de5254886c1f0 Mon Sep 17 00:00:00 2001 From: sergeych Date: Wed, 14 Jan 2026 13:34:08 +0300 Subject: [PATCH] plugin: run command. Lyng string "hello"*repeatCount operator. Plugin spell check is still not working properly --- docs/tutorial.md | 10 +- .../lyng/idea/actions/RunLyngScriptAction.kt | 144 ++++ .../idea/annotators/LyngExternalAnnotator.kt | 101 ++- .../lyng/idea/grazie/LyngGrazieAnnotator.kt | 635 ------------------ .../lyng/idea/grazie/LyngGrazieStrategy.kt | 10 +- .../lyng/idea/grazie/LyngTextExtractor.kt | 10 +- .../idea/spell/LyngSpellcheckingStrategy.kt | 113 +--- .../src/main/resources/META-INF/plugin.xml | 15 +- .../main/resources/META-INF/spellchecker.xml | 5 +- .../kotlin/net/sergeych/lyng/Compiler.kt | 5 +- .../lyng/highlight/SimpleLyngHighlighter.kt | 2 +- .../net/sergeych/lyng/miniast/MiniAst.kt | 1 + .../net/sergeych/lyng/obj/ObjException.kt | 45 +- .../kotlin/net/sergeych/lyng/obj/ObjString.kt | 8 + lynglib/src/commonTest/kotlin/MiniAstTest.kt | 1 + lynglib/src/commonTest/kotlin/ScriptTest.kt | 31 + lynglib/stdlib/lyng/root.lyng | 2 +- 17 files changed, 354 insertions(+), 784 deletions(-) create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/actions/RunLyngScriptAction.kt delete mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieAnnotator.kt diff --git a/docs/tutorial.md b/docs/tutorial.md index 2f4c1ca..3696788 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -1252,7 +1252,7 @@ The same with `--`: sum >>> 5050 -There are self-assigning version for operators too: +There is a self-assigning version for operators too: var count = 100 var sum = 0 @@ -1471,7 +1471,13 @@ Part match: assert( "foo" == $~.value ) >>> void -Typical set of String functions includes: +Repeating the fragment: + + assertEquals("hellohello", "hello"*2) + assertEquals("", "hello"*0) + >>> void + +A typical set of String functions includes: | fun/prop | description / notes | |----------------------|------------------------------------------------------------| diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/actions/RunLyngScriptAction.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/actions/RunLyngScriptAction.kt new file mode 100644 index 0000000..1104024 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/actions/RunLyngScriptAction.kt @@ -0,0 +1,144 @@ +/* + * 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.actions + +import com.intellij.execution.filters.TextConsoleBuilderFactory +import com.intellij.execution.ui.ConsoleView +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowAnchor +import com.intellij.openapi.wm.ToolWindowId +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiManager +import com.intellij.ui.content.ContentFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import net.sergeych.lyng.ExecutionError +import net.sergeych.lyng.Script +import net.sergeych.lyng.Source +import net.sergeych.lyng.idea.LyngIcons +import net.sergeych.lyng.obj.ObjVoid +import net.sergeych.lyng.obj.getLyngExceptionMessageWithStackTrace + +class RunLyngScriptAction : AnAction(LyngIcons.FILE) { + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + private fun getPsiFile(e: AnActionEvent): PsiFile? { + val project = e.project ?: return null + return e.getData(CommonDataKeys.PSI_FILE) ?: run { + val vf = e.getData(CommonDataKeys.VIRTUAL_FILE) + if (vf != null) PsiManager.getInstance(project).findFile(vf) else null + } + } + + override fun update(e: AnActionEvent) { + val psiFile = getPsiFile(e) + val isLyng = psiFile?.name?.endsWith(".lyng") == true + e.presentation.isEnabledAndVisible = isLyng + if (isLyng) { + e.presentation.text = "Run '${psiFile.name}'" + } else { + e.presentation.text = "Run Lyng Script" + } + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val psiFile = getPsiFile(e) ?: return + val text = psiFile.text + val fileName = psiFile.name + + val (console, toolWindow) = getConsoleAndToolWindow(project) + console.clear() + + toolWindow.show { + scope.launch { + try { + val lyngScope = Script.newScope() + lyngScope.addFn("print") { + val sb = StringBuilder() + for ((i, arg) in args.list.withIndex()) { + if (i > 0) sb.append(" ") + sb.append(arg.toString(this).value) + } + console.print(sb.toString(), ConsoleViewContentType.NORMAL_OUTPUT) + ObjVoid + } + lyngScope.addFn("println") { + val sb = StringBuilder() + for ((i, arg) in args.list.withIndex()) { + if (i > 0) sb.append(" ") + sb.append(arg.toString(this).value) + } + console.print(sb.toString() + "\n", ConsoleViewContentType.NORMAL_OUTPUT) + ObjVoid + } + + console.print("--- Running $fileName ---\n", ConsoleViewContentType.SYSTEM_OUTPUT) + val result = lyngScope.eval(Source(fileName, text)) + console.print("\n--- Finished with result: ${result.inspect(lyngScope)} ---\n", ConsoleViewContentType.SYSTEM_OUTPUT) + } catch (t: Throwable) { + console.print("\n--- Error ---\n", ConsoleViewContentType.ERROR_OUTPUT) + if( t is ExecutionError ) { + val m = t.errorObject.getLyngExceptionMessageWithStackTrace() + console.print(m, ConsoleViewContentType.ERROR_OUTPUT) + } + else + console.print(t.message ?: t.toString(), ConsoleViewContentType.ERROR_OUTPUT) + console.print("\n", ConsoleViewContentType.ERROR_OUTPUT) + } + } + } + } + + private fun getConsoleAndToolWindow(project: Project): Pair { + val toolWindowManager = ToolWindowManager.getInstance(project) + var toolWindow = toolWindowManager.getToolWindow(ToolWindowId.RUN) + if (toolWindow == null) { + toolWindow = toolWindowManager.getToolWindow(ToolWindowId.MESSAGES_WINDOW) + } + if (toolWindow == null) { + toolWindow = toolWindowManager.getToolWindow("Lyng") + } + val actualToolWindow = toolWindow ?: run { + @Suppress("DEPRECATION") + toolWindowManager.registerToolWindow("Lyng", true, ToolWindowAnchor.BOTTOM) + } + + val contentManager = actualToolWindow.contentManager + val existingContent = contentManager.findContent("Lyng Run") + if (existingContent != null) { + val console = existingContent.component as ConsoleView + contentManager.setSelectedContent(existingContent) + return console to actualToolWindow + } + + val console = TextConsoleBuilderFactory.getInstance().createBuilder(project).console + val content = ContentFactory.getInstance().createContent(console.component, "Lyng Run", false) + contentManager.addContent(content) + contentManager.setSelectedContent(content) + return console to actualToolWindow + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt index 9c5681c..3634e1e 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt @@ -318,32 +318,107 @@ class LyngExternalAnnotator : ExternalAnnotator - if (s.kind == HighlightKind.EnumConstant) { - val start = s.range.start - val end = s.range.endExclusive - if (start in 0..end && end <= text.length && start < end) { - putRange(start, end, LyngHighlighterColors.ENUM_CONSTANT) + // 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() + 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 + val end = s.range.endExclusive + if (start in 0..end && end <= text.length && start < end) { + putRange(start, end, LyngHighlighterColors.ENUM_CONSTANT) + } } } - // Build spell index payload: identifiers + comments/strings from simple highlighter. - // We use the highlighter as the source of truth for all "words" to check, including - // identifiers that might not be bound by the Binder. - val idRanges = tokens.filter { it.kind == HighlightKind.Identifier }.map { it.range.start until it.range.endExclusive } val commentRanges = tokens.filter { it.kind == HighlightKind.Comment }.map { it.range.start until it.range.endExclusive } val stringRanges = tokens.filter { it.kind == HighlightKind.String }.map { it.range.start until it.range.endExclusive } return Result(collectedInfo.modStamp, out, null, - spellIdentifiers = idRanges.toList(), + 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) { if (annotationResult == null) return // Skip if cache is up-to-date diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieAnnotator.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieAnnotator.kt deleted file mode 100644 index a9a14ef..0000000 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieAnnotator.kt +++ /dev/null @@ -1,635 +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. - * - */ - -/* - * Grazie-backed annotator for Lyng files. - * - * It consumes the MiniAst-driven LyngSpellIndex and, when Grazie is present, - * tries to run Grazie checks on the extracted TextContent. Results are painted - * as warnings in the editor. If the Grazie API changes, we use reflection and - * fail softly with INFO logs (no errors shown to users). - */ -package net.sergeych.lyng.idea.grazie - -import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer -import com.intellij.grazie.text.TextContent -import com.intellij.grazie.text.TextContent.TextDomain -import com.intellij.ide.plugins.PluginManagerCore -import com.intellij.lang.annotation.AnnotationHolder -import com.intellij.lang.annotation.ExternalAnnotator -import com.intellij.lang.annotation.HighlightSeverity -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.editor.Document -import com.intellij.openapi.editor.colors.TextAttributesKey -import com.intellij.openapi.project.DumbAware -import com.intellij.openapi.util.Key -import com.intellij.openapi.util.TextRange -import com.intellij.psi.PsiFile -import net.sergeych.lyng.idea.settings.LyngFormatterSettings -import net.sergeych.lyng.idea.spell.LyngSpellIndex - -class LyngGrazieAnnotator : ExternalAnnotator(), DumbAware { - private val log = Logger.getInstance(LyngGrazieAnnotator::class.java) - - companion object { - // Cache GrammarChecker availability to avoid repeated reflection + noisy logs - @Volatile - private var grammarCheckerAvailable: Boolean? = null - - @Volatile - private var grammarCheckerMissingLogged: Boolean = false - - private fun isGrammarCheckerKnownMissing(): Boolean = (grammarCheckerAvailable == false) - - private fun markGrammarCheckerMissingOnce(log: Logger, message: String) { - if (!grammarCheckerMissingLogged) { - // Downgrade to debug to reduce log noise across projects/sessions - log.debug(message) - grammarCheckerMissingLogged = true - } - } - - private val RETRY_KEY: Key = Key.create("LYNG_GRAZIE_ANN_RETRY_STAMP") - } - - data class Input(val modStamp: Long) - data class Finding(val range: TextRange, val message: String) - data class Result(val modStamp: Long, val findings: List) - - override fun collectInformation(file: PsiFile): Input? { - val doc: Document = file.viewProvider.document ?: return null - // Only require Grazie presence; index readiness is checked in apply with a retry. - val grazie = isGrazieInstalled() - if (!grazie) { - log.info("LyngGrazieAnnotator.collectInformation: skip (grazie=false) file='${file.name}'") - return null - } - log.info("LyngGrazieAnnotator.collectInformation: file='${file.name}', modStamp=${doc.modificationStamp}") - return Input(doc.modificationStamp) - } - - override fun doAnnotate(collectedInfo: Input?): Result? { - // All heavy lifting is done in apply where we have the file context - return collectedInfo?.let { Result(it.modStamp, emptyList()) } - } - - override fun apply(file: PsiFile, annotationResult: Result?, holder: AnnotationHolder) { - if (annotationResult == null || !isGrazieInstalled()) return - val doc = file.viewProvider.document ?: return - val idx = LyngSpellIndex.getUpToDate(file) ?: run { - log.info("LyngGrazieAnnotator.apply: index not ready for '${file.name}', scheduling one-shot restart") - scheduleOneShotRestart(file, annotationResult.modStamp) - return - } - - val settings = LyngFormatterSettings.getInstance(file.project) - - // Build TextContent fragments for comments/strings/identifiers according to settings - val fragments = mutableListOf>() - try { - fun addFragments(ranges: List, domain: TextDomain) { - for (r in ranges) { - val local = rangeToTextContent(file, domain, r) ?: continue - fragments += local to r - } - } - // Comments always via COMMENTS - addFragments(idx.comments, TextDomain.COMMENTS) - // Strings: LITERALS if requested, else COMMENTS if fallback enabled - if (settings.spellCheckStringLiterals) { - val domain = if (settings.grazieTreatLiteralsAsComments) TextDomain.COMMENTS else TextDomain.LITERALS - addFragments(idx.strings, domain) - } - // Identifiers via COMMENTS to force painting in 243 unless user disables fallback - val idsDomain = if (settings.grazieTreatIdentifiersAsComments) TextDomain.COMMENTS else TextDomain.DOCUMENTATION - addFragments(idx.identifiers, idsDomain) - log.info( - "LyngGrazieAnnotator.apply: file='${file.name}', idxCounts ids=${idx.identifiers.size}, comments=${idx.comments.size}, strings=${idx.strings.size}, builtFragments=${fragments.size}" - ) - } catch (e: Throwable) { - log.info("LyngGrazieAnnotator: failed to build TextContent fragments: ${e.javaClass.simpleName}: ${e.message}") - return - } - - if (fragments.isEmpty()) return - - val findings = mutableListOf() - var totalReturned = 0 - var chosenEntry: String? = null - for ((content, hostRange) in fragments) { - try { - val (typos, entryNote) = runGrazieChecksWithTracing(file, content) - if (chosenEntry == null) chosenEntry = entryNote - if (typos != null) { - totalReturned += typos.size - for (t in typos) { - val rel = extractRangeFromTypo(t) ?: continue - // Map relative range inside fragment to host file range - val abs = TextRange(hostRange.startOffset + rel.startOffset, hostRange.startOffset + rel.endOffset) - findings += Finding(abs, extractMessageFromTypo(t) ?: "Spelling/Grammar") - } - } - } catch (e: Throwable) { - log.info("LyngGrazieAnnotator: Grazie check failed: ${e.javaClass.simpleName}: ${e.message}") - } - } - log.info("LyngGrazieAnnotator.apply: used=${chosenEntry ?: ""}, totalFindings=$totalReturned, painting=${findings.size}") - - for (f in findings) { - val ab = holder.newAnnotation(HighlightSeverity.INFORMATION, f.message).range(f.range) - applyTypoStyleIfRequested(file, ab) - ab.create() - } - - // SUPPLEMENT: Always run the fallback spellchecker to ensure spelling errors are not ignored. - // It will avoid duplicating findings already reported by Grazie. - val painted = fallbackWithLegacySpellcheckerIfAvailable(file, fragments, holder, findings) - if (painted > 0) { - log.info("LyngGrazieAnnotator.apply: supplemented with $painted typos from legacy engine") - } - } - - private fun scheduleOneShotRestart(file: PsiFile, modStamp: Long) { - try { - val last = file.getUserData(RETRY_KEY) - if (last == modStamp) { - log.info("LyngGrazieAnnotator.restart: already retried for modStamp=$modStamp, skip") - return - } - file.putUserData(RETRY_KEY, modStamp) - ApplicationManager.getApplication().invokeLater({ - try { - DaemonCodeAnalyzer.getInstance(file.project).restart(file) - log.info("LyngGrazieAnnotator.restart: daemon restarted for '${file.name}'") - } catch (e: Throwable) { - log.info("LyngGrazieAnnotator.restart failed: ${e.javaClass.simpleName}: ${e.message}") - } - }) - } catch (e: Throwable) { - log.info("LyngGrazieAnnotator.scheduleOneShotRestart failed: ${e.javaClass.simpleName}: ${e.message}") - } - } - - private fun isGrazieInstalled(): Boolean { - return PluginManagerCore.isPluginInstalled(com.intellij.openapi.extensions.PluginId.getId("com.intellij.grazie")) || - PluginManagerCore.isPluginInstalled(com.intellij.openapi.extensions.PluginId.getId("tanvd.grazi")) - } - - private fun rangeToTextContent(file: PsiFile, domain: TextDomain, range: TextRange): TextContent? { - // Build TextContent via reflection: prefer psiFragment(domain, element) - return try { - // Try to find an element that fully covers the target range - var element = file.findElementAt(range.startOffset) ?: return null - val start = range.startOffset - val end = range.endOffset - while (element.parent != null && (element.textRange.startOffset > start || element.textRange.endOffset < end)) { - element = element.parent - } - if (element.textRange.startOffset > start || element.textRange.endOffset < end) return null - // In many cases, the element may not span the whole range; use file + range via suitable factory - val methods = TextContent::class.java.methods.filter { it.name == "psiFragment" } - val byElementDomain = methods.firstOrNull { it.parameterCount == 2 && it.parameterTypes[0].name.endsWith("PsiElement") } - if (byElementDomain != null) { - @Suppress("UNCHECKED_CAST") - return (byElementDomain.invoke(null, element, domain) as? TextContent)?.let { tc -> - val relStart = start - element.textRange.startOffset - val relEnd = end - element.textRange.startOffset - if (relStart < 0 || relEnd > tc.length || relStart >= relEnd) return null - tc.subText(TextRange(relStart, relEnd)) - } - } - val byDomainElement = methods.firstOrNull { it.parameterCount == 2 && it.parameterTypes[0].name.endsWith("TextDomain") } - if (byDomainElement != null) { - @Suppress("UNCHECKED_CAST") - return (byDomainElement.invoke(null, domain, element) as? TextContent)?.let { tc -> - val relStart = start - element.textRange.startOffset - val relEnd = end - element.textRange.startOffset - if (relStart < 0 || relEnd > tc.length || relStart >= relEnd) return null - tc.subText(TextRange(relStart, relEnd)) - } - } - null - } catch (e: Throwable) { - log.info("LyngGrazieAnnotator: rangeToTextContent failed: ${e.javaClass.simpleName}: ${e.message}") - null - } - } - - private fun runGrazieChecksWithTracing(file: PsiFile, content: TextContent): Pair?, String?> { - // Try known entry points via reflection to avoid hard dependencies on Grazie internals - if (isGrammarCheckerKnownMissing()) return null to null - try { - // 1) Static GrammarChecker.check(TextContent) - val checkerCls = try { - Class.forName("com.intellij.grazie.grammar.GrammarChecker").also { grammarCheckerAvailable = true } - } catch (t: Throwable) { - grammarCheckerAvailable = false - markGrammarCheckerMissingOnce(log, "LyngGrazieAnnotator: GrammarChecker class not found: ${t.javaClass.simpleName}: ${t.message}") - null - } - if (checkerCls != null) { - // Diagnostic: list available 'check' methods once - runCatching { - val checks = checkerCls.methods.filter { it.name == "check" } - val sig = checks.joinToString { m -> - val params = m.parameterTypes.joinToString(prefix = "(", postfix = ")") { it.simpleName } - "${m.name}$params static=${java.lang.reflect.Modifier.isStatic(m.modifiers)}" - } - log.info("LyngGrazieAnnotator: GrammarChecker.check candidates: ${if (sig.isEmpty()) "" else sig}") - } - checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") }?.let { m -> - @Suppress("UNCHECKED_CAST") - val res = m.invoke(null, content) as? Collection - return res to "GrammarChecker.check(TextContent) static" - } - // 2) GrammarChecker.getInstance().check(TextContent) - val getInstance = checkerCls.methods.firstOrNull { it.name == "getInstance" && it.parameterCount == 0 } - val inst = getInstance?.invoke(null) - if (inst != null) { - val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") } - if (m != null) { - @Suppress("UNCHECKED_CAST") - val res = m.invoke(inst, content) as? Collection - return res to "GrammarChecker.getInstance().check(TextContent)" - } - } - // 3) GrammarChecker.getDefault().check(TextContent) - val getDefault = checkerCls.methods.firstOrNull { it.name == "getDefault" && it.parameterCount == 0 } - val def = getDefault?.invoke(null) - if (def != null) { - val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") } - if (m != null) { - @Suppress("UNCHECKED_CAST") - val res = m.invoke(def, content) as? Collection - return res to "GrammarChecker.getDefault().check(TextContent)" - } - } - // 4) Service from project/application: GrammarChecker as a service - runCatching { - val app = com.intellij.openapi.application.ApplicationManager.getApplication() - val getService = app::class.java.methods.firstOrNull { it.name == "getService" && it.parameterCount == 1 } - val svc = getService?.invoke(app, checkerCls) - if (svc != null) { - val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") } - if (m != null) { - @Suppress("UNCHECKED_CAST") - val res = m.invoke(svc, content) as? Collection - if (res != null) return res to "Application.getService(GrammarChecker).check(TextContent)" - } - } - } - runCatching { - val getService = file.project::class.java.methods.firstOrNull { it.name == "getService" && it.parameterCount == 1 } - val svc = getService?.invoke(file.project, checkerCls) - if (svc != null) { - val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") } - if (m != null) { - @Suppress("UNCHECKED_CAST") - val res = m.invoke(svc, content) as? Collection - if (res != null) return res to "Project.getService(GrammarChecker).check(TextContent)" - } - } - } - } - // 5) Fallback: search any public method named check that accepts TextContent in any Grazie class (static) - val candidateClasses = listOf( - "com.intellij.grazie.grammar.GrammarChecker", - "com.intellij.grazie.grammar.GrammarRunner", - "com.intellij.grazie.grammar.Grammar" // historical names - ) - for (cn in candidateClasses) { - val cls = try { Class.forName(cn) } catch (_: Throwable) { continue } - val m = cls.methods.firstOrNull { it.name == "check" && it.parameterTypes.any { p -> p.name.endsWith("TextContent") } } - if (m != null) { - val args = arrayOfNulls(m.parameterCount) - // place content to the first TextContent parameter; others left null (common defaults) - for (i in 0 until m.parameterCount) if (m.parameterTypes[i].name.endsWith("TextContent")) { args[i] = content; break } - @Suppress("UNCHECKED_CAST") - val res = m.invoke(null, *args) as? Collection - if (res != null) return res to "$cn.${m.name}(TextContent)" - } - } - // 6) Kotlin top-level function: GrammarCheckerKt.check(TextContent) - runCatching { - val kt = Class.forName("com.intellij.grazie.grammar.GrammarCheckerKt") - val m = kt.methods.firstOrNull { it.name == "check" && it.parameterTypes.any { p -> p.name.endsWith("TextContent") } } - if (m != null) { - val args = arrayOfNulls(m.parameterCount) - for (i in 0 until m.parameterCount) if (m.parameterTypes[i].name.endsWith("TextContent")) { args[i] = content; break } - @Suppress("UNCHECKED_CAST") - val res = m.invoke(null, *args) as? Collection - if (res != null) return res to "GrammarCheckerKt.check(TextContent)" - } - } - } catch (e: Throwable) { - log.info("LyngGrazieAnnotator: runGrazieChecks reflection failed: ${e.javaClass.simpleName}: ${e.message}") - } - return null to null - } - - private fun extractRangeFromTypo(typo: Any): TextRange? { - // Try to get a relative range from returned Grazie issue/typo via common accessors - return try { - // Common getters - val m1 = typo.javaClass.methods.firstOrNull { it.name == "getRange" && it.parameterCount == 0 } - val r1 = if (m1 != null) m1.invoke(typo) else null - when (r1) { - is TextRange -> return r1 - is IntRange -> return TextRange(r1.first, r1.last + 1) - } - val m2 = typo.javaClass.methods.firstOrNull { it.name == "getHighlightRange" && it.parameterCount == 0 } - val r2 = if (m2 != null) m2.invoke(typo) else null - when (r2) { - is TextRange -> return r2 - is IntRange -> return TextRange(r2.first, r2.last + 1) - } - // Separate from/to ints - val fromM = typo.javaClass.methods.firstOrNull { it.name == "getFrom" && it.parameterCount == 0 && it.returnType == Int::class.javaPrimitiveType } - val toM = typo.javaClass.methods.firstOrNull { it.name == "getTo" && it.parameterCount == 0 && it.returnType == Int::class.javaPrimitiveType } - if (fromM != null && toM != null) { - val s = (fromM.invoke(typo) as? Int) ?: return null - val e = (toM.invoke(typo) as? Int) ?: return null - if (e > s) return TextRange(s, e) - } - null - } catch (_: Throwable) { null } - } - - private fun extractMessageFromTypo(typo: Any): String? { - return try { - val m = typo.javaClass.methods.firstOrNull { it.name == "getMessage" && it.parameterCount == 0 } - (m?.invoke(typo) as? String) - } catch (_: Throwable) { null } - } - - - // Fallback that uses legacy SpellCheckerManager (if present) via reflection to validate words in fragments. - // Returns number of warnings painted. - private fun fallbackWithLegacySpellcheckerIfAvailable( - file: PsiFile, - fragments: List>, - holder: AnnotationHolder, - existingFindings: List - ): Int { - return try { - val mgrCls = Class.forName("com.intellij.spellchecker.SpellCheckerManager") - val getInstance = mgrCls.methods.firstOrNull { it.name == "getInstance" && it.parameterCount == 1 } - val isCorrect = mgrCls.methods.firstOrNull { it.name == "isCorrect" && it.parameterCount == 1 && it.parameterTypes[0] == String::class.java } - if (getInstance == null || isCorrect == null) { - // No legacy spellchecker API available — fall back to naive painter - return naiveFallbackPaint(file, fragments, holder, existingFindings) - } - val mgr = getInstance.invoke(null, file.project) - if (mgr == null) { - // Legacy manager not present for this project — use naive fallback - return naiveFallbackPaint(file, fragments, holder, existingFindings) - } - var painted = 0 - val docText = file.viewProvider.document?.text ?: return 0 - val tokenRegex = Regex("[A-Za-z][A-Za-z0-9_']{2,}") - val settings = LyngFormatterSettings.getInstance(file.project) - val learned = settings.learnedWords - for ((content, hostRange) in fragments) { - val text = try { docText.substring(hostRange.startOffset, hostRange.endOffset) } catch (_: Throwable) { null } ?: continue - var seen = 0 - var flagged = 0 - for (m in tokenRegex.findAll(text)) { - val token = m.value - if ('%' in token) continue // skip printf fragments defensively - // Split snake_case and camelCase within the token - val parts = splitIdentifier(token) - for (part in parts) { - if (part.length <= 2) continue - if (isAllowedWord(part, learned)) continue - - // Map part back to original token occurrence within this hostRange - val localStart = m.range.first + token.indexOf(part) - val localEnd = localStart + part.length - val abs = TextRange(hostRange.startOffset + localStart, hostRange.startOffset + localEnd) - - // Avoid duplicating findings from Grazie - if (existingFindings.any { it.range.intersects(abs) }) continue - - // Quick allowlist for very common words to reduce noise if dictionaries differ - val ok = try { isCorrect.invoke(mgr, part) as? Boolean } catch (_: Throwable) { null } - if (ok == false) { - paintTypoAnnotation(file, holder, abs, part) - painted++ - flagged++ - } - seen++ - } - } - log.info("LyngGrazieAnnotator.fallback: fragment words=$seen, flagged=$flagged") - } - painted - } catch (_: Throwable) { - // If legacy manager is not available, fall back to a very naive heuristic (no external deps) - return naiveFallbackPaint(file, fragments, holder, existingFindings) - } - } - - private fun naiveFallbackPaint( - file: PsiFile, - fragments: List>, - holder: AnnotationHolder, - existingFindings: List - ): Int { - var painted = 0 - val docText = file.viewProvider.document?.text - val tokenRegex = Regex("[A-Za-z][A-Za-z0-9_']{2,}") - val settings = LyngFormatterSettings.getInstance(file.project) - val learned = settings.learnedWords - val baseWords = setOf( - // small, common vocabulary to catch near-miss typos in typical code/comments - "comment","comments","error","errors","found","file","not","word","words","count","value","name","class","function","string" - ) - for ((content, hostRange) in fragments) { - val text: String? = docText?.let { dt -> - try { dt.substring(hostRange.startOffset, hostRange.endOffset) } catch (_: Throwable) { null } - } - if (text.isNullOrBlank()) continue - var seen = 0 - var flagged = 0 - for (m in tokenRegex.findAll(text)) { - val token = m.value - if ('%' in token) continue - val parts = splitIdentifier(token) - for (part in parts) { - seen++ - val lower = part.lowercase() - if (lower.length <= 2 || isAllowedWord(part, learned)) continue - - val localStart = m.range.first + token.indexOf(part) - val localEnd = localStart + part.length - val abs = TextRange(hostRange.startOffset + localStart, hostRange.startOffset + localEnd) - - // Avoid duplicating findings from Grazie - if (existingFindings.any { it.range.intersects(abs) }) continue - - // Heuristic: no vowels OR 3 repeated chars OR ends with unlikely double consonants - val noVowel = lower.none { it in "aeiouy" } - val triple = Regex("(.)\\1\\1").containsMatchIn(lower) - val dblCons = Regex("[bcdfghjklmnpqrstvwxyz]{2}$").containsMatchIn(lower) - var looksWrong = noVowel || triple || dblCons - // Additional: low vowel ratio for length>=4 - if (!looksWrong && lower.length >= 4) { - val vowels = lower.count { it in "aeiouy" } - val ratio = if (lower.isNotEmpty()) vowels.toDouble() / lower.length else 1.0 - if (ratio < 0.25) looksWrong = true - } - // Additional: near-miss to a small base vocabulary (edit distance 1, or 2 for words >=6) - if (!looksWrong) { - for (bw in baseWords) { - val d = editDistance(lower, bw) - if (d == 1 || (d == 2 && lower.length >= 6)) { looksWrong = true; break } - } - } - if (looksWrong) { - paintTypoAnnotation(file, holder, abs, part) - painted++ - flagged++ - } - } - } - log.info("LyngGrazieAnnotator.fallback(naive): fragment words=$seen, flagged=$flagged") - } - return painted - } - - private fun paintTypoAnnotation(file: PsiFile, holder: AnnotationHolder, range: TextRange, word: String) { - val settings = LyngFormatterSettings.getInstance(file.project) - val ab = holder.newAnnotation(HighlightSeverity.INFORMATION, "Possible typo") - .range(range) - applyTypoStyleIfRequested(file, ab) - if (settings.offerLyngTypoQuickFixes) { - // Offer lightweight fixes; for 243 provide Add-to-dictionary always - ab.withFix(net.sergeych.lyng.idea.grazie.AddToLyngDictionaryFix(word)) - // Offer "Replace with…" candidates (top 7) - val cands = suggestReplacements(file, word).take(7) - for (c in cands) { - ab.withFix(net.sergeych.lyng.idea.grazie.ReplaceWordFix(range, word, c)) - } - } - ab.create() - } - - private fun applyTypoStyleIfRequested(file: PsiFile, ab: com.intellij.lang.annotation.AnnotationBuilder) { - val settings = LyngFormatterSettings.getInstance(file.project) - if (!settings.showTyposWithGreenUnderline) return - // Use the standard TYPO text attributes key used by the platform - val TYPO: TextAttributesKey = TextAttributesKey.createTextAttributesKey("TYPO") - try { - ab.textAttributes(TYPO) - } catch (_: Throwable) { - // some IDEs may not allow setting attributes on INFORMATION; ignore gracefully - } - } - - private fun suggestReplacements(file: PsiFile, word: String): List { - val lower = word.lowercase() - val fromProject = collectProjectWords(file) - - val fromSpellChecker = try { - val mgrCls = Class.forName("com.intellij.spellchecker.SpellCheckerManager") - val getInstance = mgrCls.methods.firstOrNull { it.name == "getInstance" && it.parameterCount == 1 } - val getSuggestions = mgrCls.methods.firstOrNull { it.name == "getSuggestions" && it.parameterCount == 1 && it.parameterTypes[0] == String::class.java } - val mgr = getInstance?.invoke(null, file.project) - if (mgr != null && getSuggestions != null) { - @Suppress("UNCHECKED_CAST") - getSuggestions.invoke(mgr, word) as? List - } else null - } catch (_: Throwable) { - null - } ?: emptyList() - - // Merge with priority: project (p=0), spellchecker (p=1) - val all = LinkedHashSet() - // Add project words that are close enough - for (w in fromProject) { - if (w == lower) continue - if (kotlin.math.abs(w.length - lower.length) <= 2 && editDistance(lower, w) <= 2) { - all.add(w) - } - } - all.addAll(fromSpellChecker) - - return all.take(16).toList() - } - - private fun collectProjectWords(file: PsiFile): Set { - // Simple approach: use current file text; can be extended to project scanning later - val text = file.viewProvider.document?.text ?: return emptySet() - val out = LinkedHashSet() - val tokenRegex = Regex("[A-Za-z][A-Za-z0-9_']{2,}") - for (m in tokenRegex.findAll(text)) { - val parts = splitIdentifier(m.value) - parts.forEach { out += it.lowercase() } - } - // Include learned words - val settings = LyngFormatterSettings.getInstance(file.project) - out.addAll(settings.learnedWords.map { it.lowercase() }) - return out - } - - private fun splitIdentifier(token: String): List { - // Split on underscores and camelCase boundaries - val unders = token.split('_').filter { it.isNotBlank() } - val out = mutableListOf() - val camelBoundary = Regex("(?<=[a-z])(?=[A-Z])") - for (u in unders) out += u.split(camelBoundary).filter { it.isNotBlank() } - return out - } - - private fun isAllowedWord(w: String, learnedWords: Set = emptySet()): Boolean { - val s = w.lowercase() - if (s in learnedWords) return true - return s in setOf( - // common code words / language keywords to avoid noise - "val","var","fun","class","interface","enum","type","import","package","return","if","else","when","while","for","try","catch","finally","true","false","null", - "abstract","closed","override", - // very common English words - "the","and","or","not","with","from","into","this","that","file","found","count","name","value","object", - // Lyng technical/vocabulary words formerly in TechDictionary - "lyng","miniast","binder","printf","specifier","specifiers","regex","token","tokens", - "identifier","identifiers","keyword","keywords","comment","comments","string","strings", - "literal","literals","formatting","formatter","grazie","typo","typos","dictionary","dictionaries" - ) - } - - private fun editDistance(a: String, b: String): Int { - if (a == b) return 0 - if (a.isEmpty()) return b.length - if (b.isEmpty()) return a.length - val dp = IntArray(b.length + 1) { it } - for (i in 1..a.length) { - var prev = dp[0] - dp[0] = i - for (j in 1..b.length) { - val temp = dp[j] - dp[j] = minOf( - dp[j] + 1, // deletion - dp[j - 1] + 1, // insertion - prev + if (a[i - 1] == b[j - 1]) 0 else 1 // substitution - ) - prev = temp - } - } - return dp[b.length] - } -} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieStrategy.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieStrategy.kt index 0cab36f..8feda3f 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieStrategy.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngGrazieStrategy.kt @@ -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,7 +21,6 @@ import com.intellij.grazie.grammar.strategy.GrammarCheckingStrategy.TextDomain import com.intellij.ide.plugins.PluginManagerCore import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.extensions.PluginId -import com.intellij.openapi.util.TextRange import com.intellij.psi.PsiElement import net.sergeych.lyng.idea.highlight.LyngTokenTypes import net.sergeych.lyng.idea.settings.LyngFormatterSettings @@ -81,17 +80,16 @@ class LyngGrazieStrategy : GrammarCheckingStrategy { val index = if (file != null) LyngSpellIndex.getUpToDate(file) else null val r = root.textRange - fun overlaps(list: List): Boolean = r != null && list.any { it.intersects(r) } - return when (type) { LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT -> TextDomain.COMMENTS LyngTokenTypes.STRING -> if (settings.grazieTreatLiteralsAsComments) TextDomain.COMMENTS else TextDomain.LITERALS LyngTokenTypes.IDENTIFIER -> { - // For Grazie-only reliability in 243, route identifiers via COMMENTS when configured - if (settings.grazieTreatIdentifiersAsComments && index != null && r != null && overlaps(index.identifiers)) + // 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 } } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngTextExtractor.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngTextExtractor.kt index e1f82f2..ef08fdf 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngTextExtractor.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/grazie/LyngTextExtractor.kt @@ -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. @@ -46,14 +46,12 @@ class LyngTextExtractor : TextExtractor() { val index = if (file != null) LyngSpellIndex.getUpToDate(file) else null val r = element.textRange - fun overlaps(list: List): Boolean = r != null && list.any { it.intersects(r) } - // Decide target domain by intersection with our MiniAst-driven index; prefer comments > strings > identifiers var domain: TextDomain? = null if (index != null && r != null) { - if (overlaps(index.comments)) domain = TextDomain.COMMENTS - else if (overlaps(index.strings) && settings.spellCheckStringLiterals) domain = TextDomain.LITERALS - else if (overlaps(index.identifiers)) domain = if (settings.grazieTreatIdentifiersAsComments) TextDomain.COMMENTS else TextDomain.DOCUMENTATION + 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) { diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/spell/LyngSpellcheckingStrategy.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/spell/LyngSpellcheckingStrategy.kt index f06d39e..86084e4 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/spell/LyngSpellcheckingStrategy.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/spell/LyngSpellcheckingStrategy.kt @@ -16,106 +16,37 @@ */ package net.sergeych.lyng.idea.spell -// Avoid Tokenizers helper to keep compatibility; implement our own tokenizers -import com.intellij.openapi.util.TextRange import com.intellij.psi.PsiElement -import com.intellij.spellchecker.inspections.PlainTextSplitter import com.intellij.spellchecker.tokenizer.SpellcheckingStrategy -import com.intellij.spellchecker.tokenizer.TokenConsumer import com.intellij.spellchecker.tokenizer.Tokenizer -import net.sergeych.lyng.idea.settings.LyngFormatterSettings +import net.sergeych.lyng.idea.highlight.LyngTokenTypes /** - * Spellchecking strategy for Lyng: - * - Identifiers: checked as identifiers - * - Comments: checked as plain text - * - Keywords: skipped - * - String literals: optional (controlled by settings), and we exclude printf-style format specifiers like - * %s, %d, %-12s, %0.2f, etc. + * Standard IntelliJ spellchecking strategy for Lyng. + * It uses the MiniAst-driven [LyngSpellIndex] to limit identifier checks to declarations only. */ class LyngSpellcheckingStrategy : SpellcheckingStrategy() { - - override fun getTokenizer(element: PsiElement): Tokenizer<*> { - if (element is com.intellij.psi.PsiFile) return EMPTY_TOKENIZER - - val settings = LyngFormatterSettings.getInstance(element.project) - val et = element.node?.elementType - - if (et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.IDENTIFIER || et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.LABEL) { - return IDENTIFIER_TOKENIZER - } - if (et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.LINE_COMMENT || et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.BLOCK_COMMENT) { - return COMMENT_TEXT_TOKENIZER - } - if (et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.STRING && settings.spellCheckStringLiterals) { - return STRING_WITH_PRINTF_EXCLUDES - } - - return EMPTY_TOKENIZER - } - - private object EMPTY_TOKENIZER : Tokenizer() { - override fun tokenize(element: PsiElement, consumer: TokenConsumer) {} - } - - private object IDENTIFIER_TOKENIZER : Tokenizer() { - private val splitter = com.intellij.spellchecker.inspections.IdentifierSplitter.getInstance() - override fun tokenize(element: PsiElement, consumer: TokenConsumer) { - val text = element.text - if (text.isNullOrEmpty()) return - consumer.consumeToken(element, text, false, 0, TextRange(0, text.length), splitter) - } - } - - private object COMMENT_TEXT_TOKENIZER : Tokenizer() { - private val splitter = PlainTextSplitter.getInstance() - override fun tokenize(element: PsiElement, consumer: TokenConsumer) { - val text = element.text - if (text.isNullOrEmpty()) return - consumer.consumeToken(element, text, false, 0, TextRange(0, text.length), splitter) - } - } - - private object STRING_WITH_PRINTF_EXCLUDES : Tokenizer() { - private val splitter = PlainTextSplitter.getInstance() - - // Regex for printf-style specifiers: %[flags][width][.precision][length]type - // This is intentionally permissive to skip common cases like %s, %d, %-12s, %08x, %.2f, %% - private val SPEC = Regex("%(?:[-+ #0]*(?:\\d+)?(?:\\.\\d+)?[a-zA-Z%])") - - override fun tokenize(element: PsiElement, consumer: TokenConsumer) { - // Check project settings whether literals should be spell-checked - val settings = LyngFormatterSettings.getInstance(element.project) - if (!settings.spellCheckStringLiterals) return - - val text = element.text - if (text.isEmpty()) return - - // Try to strip surrounding quotes (simple lexer token for Lyng strings) - var startOffsetInElement = 0 - var endOffsetInElement = text.length - if (text.length >= 2 && (text.first() == '"' && text.last() == '"' || text.first() == '\'' && text.last() == '\'')) { - startOffsetInElement = 1 - endOffsetInElement = text.length - 1 - } - if (endOffsetInElement <= startOffsetInElement) return - - val content = text.substring(startOffsetInElement, endOffsetInElement) - - var last = 0 - for (m in SPEC.findAll(content)) { - val ms = m.range.first - val me = m.range.last + 1 - if (ms > last) { - val range = TextRange(startOffsetInElement + last, startOffsetInElement + ms) - consumer.consumeToken(element, text, false, 0, range, splitter) + override fun getTokenizer(element: PsiElement?): Tokenizer<*> { + val type = element?.node?.elementType + 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 + } } - last = me - } - if (last < content.length) { - val range = TextRange(startOffsetInElement + last, startOffsetInElement + content.length) - consumer.consumeToken(element, text, false, 0, range, splitter) + EMPTY_TOKENIZER } + else -> super.getTokenizer(element) } } } diff --git a/lyng-idea/src/main/resources/META-INF/plugin.xml b/lyng-idea/src/main/resources/META-INF/plugin.xml index bbe6af2..bb36130 100644 --- a/lyng-idea/src/main/resources/META-INF/plugin.xml +++ b/lyng-idea/src/main/resources/META-INF/plugin.xml @@ -57,9 +57,6 @@ - - - @@ -105,5 +102,15 @@ - + + + + + + + + diff --git a/lyng-idea/src/main/resources/META-INF/spellchecker.xml b/lyng-idea/src/main/resources/META-INF/spellchecker.xml index 293f6a8..9f283f0 100644 --- a/lyng-idea/src/main/resources/META-INF/spellchecker.xml +++ b/lyng-idea/src/main/resources/META-INF/spellchecker.xml @@ -1,5 +1,5 @@ - + implementationClass="net.sergeych.lyng.idea.spell.LyngSpellcheckingStrategy"/> diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index e7ac460..ad0bcc5 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -1881,6 +1881,7 @@ class Compiler( pendingDeclStart = null // so far only simplest enums: val names = mutableListOf() + val positions = mutableListOf() // skip '{' cc.skipTokenOfType(Token.Type.LBRACE) @@ -1889,6 +1890,7 @@ class Compiler( when (t.type) { Token.Type.ID -> { names += t.value + positions += t.pos val t1 = cc.nextNonWhitespace() when (t1.type) { Token.Type.COMMA -> @@ -1912,7 +1914,8 @@ class Compiler( entries = names, doc = doc, nameStart = nameToken.pos, - isExtern = isExtern + isExtern = isExtern, + entryPositions = positions ) ) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt index 1de8eb1..bd5b625 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt @@ -42,7 +42,7 @@ private val fallbackKeywordIds = setOf( "and", "or", "not", // declarations & modifiers "fun", "fn", "class", "interface", "enum", "val", "var", "import", "package", - "abstract", "closed", "override", + "abstract", "closed", "override", "public", "lazy", "dynamic", "private", "protected", "static", "open", "extern", "init", "get", "set", "by", // control flow and misc "if", "else", "when", "while", "do", "for", "try", "catch", "finally", diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt index aa31d59..8280651 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt @@ -205,6 +205,7 @@ data class MiniEnumDecl( override val nameStart: Pos, override val isExtern: Boolean = false, override val isStatic: Boolean = false, + val entryPositions: List = emptyList() ) : MiniDecl data class MiniCtorField( diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt index 0a08c6f..32e4629 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt @@ -72,10 +72,11 @@ open class ObjException( val l = getStackTrace().list return buildString { append(message.value) - for( t in l) + for (t in l) append("\n\tat ${t.toString(scope)}") } } + override suspend fun defaultToString(scope: Scope): ObjString { val at = getStackTrace().list.firstOrNull()?.toString(scope) ?: ObjString("(unknown)") @@ -100,21 +101,23 @@ open class ObjException( while (s != null) { val pos = s.pos if (pos != lastPos && !pos.currentLine.isEmpty()) { - if (maybeCls != null) { - result.list += maybeCls.callWithArgs( - scope, - pos.source.objSourceName, - ObjInt(pos.line.toLong()), - ObjInt(pos.column.toLong()), - ObjString(pos.currentLine) - ) - } else { - // Fallback textual entry if StackTraceEntry class is not available in this scope - result.list += ObjString("${pos.source.objSourceName}:${pos.line}:${pos.column}: ${pos.currentLine}") + if( (lastPos == null || (lastPos.source != pos.source || lastPos.line != pos.line)) ) { + if (maybeCls != null) { + result.list += maybeCls.callWithArgs( + scope, + pos.source.objSourceName, + ObjInt(pos.line.toLong()), + ObjInt(pos.column.toLong()), + ObjString(pos.currentLine) + ) + } 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}") + } + lastPos = pos } } s = s.parent - lastPos = pos } return result } @@ -339,8 +342,8 @@ fun Obj.isLyngException(): Boolean = isInstanceOf("Exception") /** * Get the exception message. */ -suspend fun Obj.getLyngExceptionMessage(scope: Scope?=null): String { - require( this.isLyngException() ) +suspend fun Obj.getLyngExceptionMessage(scope: Scope? = null): String { + require(this.isLyngException()) val s = scope ?: Script.newScope() return invokeInstanceMethod(s, "message").toString(s).value } @@ -356,18 +359,18 @@ suspend fun Obj.getLyngExceptionMessage(scope: Scope?=null): String { * The stack trace details each frame using indentation for clarity. * @throws IllegalArgumentException if the object is not a Lyng exception. */ -suspend fun Obj.getLyngExceptionMessageWithStackTrace(scope: Scope?=null): String { - require( this.isLyngException() ) +suspend fun Obj.getLyngExceptionMessageWithStackTrace(scope: Scope? = null,showDetails:Boolean=true): String { + require(this.isLyngException()) val s = scope ?: Script.newScope() val msg = getLyngExceptionMessage(s) val trace = getLyngExceptionStackTrace(s) var at = "unknown" - val stack = if( !trace.list.isEmpty() ) { +// var firstLine = true + val stack = if (!trace.list.isEmpty()) { val first = trace.list[0] at = (first.readField(s, "at").value as ObjString).value "\n" + trace.list.map { " at " + it.toString(s).value }.joinToString("\n") - } - else "" + } else "" return "$at: $msg$stack" } @@ -392,7 +395,7 @@ suspend fun Obj.getLyngExceptionString(scope: Scope): String = /** * Rethrow this object as a Kotlin [ExecutionError] if it's an exception. */ -suspend fun Obj.raiseAsExecutionError(scope: Scope?=null): Nothing { +suspend fun Obj.raiseAsExecutionError(scope: Scope? = null): Nothing { if (this is ObjException) raise() val sc = scope ?: Script.newScope() val msg = getLyngExceptionMessage(sc) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjString.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjString.kt index 98ed16b..92528d8 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjString.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjString.kt @@ -79,6 +79,14 @@ data class ObjString(val value: String) : Obj() { } } + override suspend fun mul(scope: Scope, other: Obj): Obj { + var times = other.toInt() + if( times < 0 ) scope.raiseIllegalArgument("negative string repetitions") + return ObjString( buildString { + while( times-- > 0 ) append(value) + }) + } + override fun hashCode(): Int { return value.hashCode() } diff --git a/lynglib/src/commonTest/kotlin/MiniAstTest.kt b/lynglib/src/commonTest/kotlin/MiniAstTest.kt index 4e3c8f5..f5af2b7 100644 --- a/lynglib/src/commonTest/kotlin/MiniAstTest.kt +++ b/lynglib/src/commonTest/kotlin/MiniAstTest.kt @@ -168,6 +168,7 @@ class MiniAstTest { assertNotNull(ed.doc) assertTrue(ed.doc.raw.contains("Enum E docs")) assertEquals(listOf("A", "B", "C"), ed.entries) + assertEquals(3, ed.entryPositions.size) assertEquals("E", ed.name) } diff --git a/lynglib/src/commonTest/kotlin/ScriptTest.kt b/lynglib/src/commonTest/kotlin/ScriptTest.kt index 08d8b88..4ac7fea 100644 --- a/lynglib/src/commonTest/kotlin/ScriptTest.kt +++ b/lynglib/src/commonTest/kotlin/ScriptTest.kt @@ -4160,6 +4160,14 @@ class ScriptTest { ) } + @Test + fun testStringMul() = runTest { + eval(""" + assertEquals("hellohello", "hello"*2) + assertEquals("", "hello"*0) + """.trimIndent()) + } + @Test fun testLogicalNot() = runTest { eval( @@ -4672,6 +4680,29 @@ class ScriptTest { // source name, in our case, is is "tc2": assertContains(x1.message!!, "tc2") } + + @Test + fun testFilterStackTrace() = runTest { + var x = try { + evalNamed( "tc1",""" + fun f2() = throw IllegalArgumentException("test3") + fun f1() = f2() + f1() + """.trimIndent()) + fail("this should throw") + } + catch(x: ExecutionError) { + x + } + assertEquals(""" + tc1:1:12: test3 + at tc1:1:12: fun f2() = throw IllegalArgumentException("test3") + at tc1:2:12: fun f1() = f2() + at tc1:3:1: f1() + """.trimIndent(),x.errorObject.getLyngExceptionMessageWithStackTrace()) + } + + @Test fun testLyngToKotlinExceptionHelpers() = runTest { var x = evalNamed( "tc1",""" diff --git a/lynglib/stdlib/lyng/root.lyng b/lynglib/stdlib/lyng/root.lyng index 97ea679..dd94c0c 100644 --- a/lynglib/stdlib/lyng/root.lyng +++ b/lynglib/stdlib/lyng/root.lyng @@ -39,7 +39,7 @@ fun Iterable.filter(predicate) { } /* - Count all items in this iterable for which predicate return true + Count all items in this iterable for which predicate returns true */ fun Iterable.count(predicate): Int { var hits = 0