plugin: run command. Lyng string "hello"*repeatCount operator. Plugin spell check is still not working properly

This commit is contained in:
Sergey Chernov 2026-01-14 13:34:08 +03:00
parent 2d2a74656c
commit 6fa57c8197
17 changed files with 354 additions and 784 deletions

View File

@ -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 |
|----------------------|------------------------------------------------------------|

View File

@ -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<ConsoleView, ToolWindow> {
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
}
}

View File

@ -318,32 +318,107 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
}
}
// Map Enum constants from token highlighter to IDEA enum constant color
run {
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 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
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

View File

@ -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<LyngGrazieAnnotator.Input, LyngGrazieAnnotator.Result>(), 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<Long> = 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<Finding>)
override fun collectInformation(file: PsiFile): Input? {
val doc: Document = file.viewProvider.document ?: return null
// Only require Grazie presence; index readiness is checked in apply with a retry.
val grazie = isGrazieInstalled()
if (!grazie) {
log.info("LyngGrazieAnnotator.collectInformation: skip (grazie=false) file='${file.name}'")
return null
}
log.info("LyngGrazieAnnotator.collectInformation: file='${file.name}', modStamp=${doc.modificationStamp}")
return Input(doc.modificationStamp)
}
override fun doAnnotate(collectedInfo: Input?): Result? {
// All heavy lifting is done in apply where we have the file context
return collectedInfo?.let { Result(it.modStamp, emptyList()) }
}
override fun apply(file: PsiFile, annotationResult: Result?, holder: AnnotationHolder) {
if (annotationResult == null || !isGrazieInstalled()) return
val doc = file.viewProvider.document ?: return
val idx = LyngSpellIndex.getUpToDate(file) ?: run {
log.info("LyngGrazieAnnotator.apply: index not ready for '${file.name}', scheduling one-shot restart")
scheduleOneShotRestart(file, annotationResult.modStamp)
return
}
val settings = LyngFormatterSettings.getInstance(file.project)
// Build TextContent fragments for comments/strings/identifiers according to settings
val fragments = mutableListOf<Pair<TextContent, TextRange>>()
try {
fun addFragments(ranges: List<TextRange>, domain: TextDomain) {
for (r in ranges) {
val local = rangeToTextContent(file, domain, r) ?: continue
fragments += local to r
}
}
// Comments always via COMMENTS
addFragments(idx.comments, TextDomain.COMMENTS)
// Strings: LITERALS if requested, else COMMENTS if fallback enabled
if (settings.spellCheckStringLiterals) {
val domain = if (settings.grazieTreatLiteralsAsComments) TextDomain.COMMENTS else TextDomain.LITERALS
addFragments(idx.strings, domain)
}
// Identifiers via COMMENTS to force painting in 243 unless user disables fallback
val idsDomain = if (settings.grazieTreatIdentifiersAsComments) TextDomain.COMMENTS else TextDomain.DOCUMENTATION
addFragments(idx.identifiers, idsDomain)
log.info(
"LyngGrazieAnnotator.apply: file='${file.name}', idxCounts ids=${idx.identifiers.size}, comments=${idx.comments.size}, strings=${idx.strings.size}, builtFragments=${fragments.size}"
)
} catch (e: Throwable) {
log.info("LyngGrazieAnnotator: failed to build TextContent fragments: ${e.javaClass.simpleName}: ${e.message}")
return
}
if (fragments.isEmpty()) return
val findings = mutableListOf<Finding>()
var totalReturned = 0
var chosenEntry: String? = null
for ((content, hostRange) in fragments) {
try {
val (typos, entryNote) = runGrazieChecksWithTracing(file, content)
if (chosenEntry == null) chosenEntry = entryNote
if (typos != null) {
totalReturned += typos.size
for (t in typos) {
val rel = extractRangeFromTypo(t) ?: continue
// Map relative range inside fragment to host file range
val abs = TextRange(hostRange.startOffset + rel.startOffset, hostRange.startOffset + rel.endOffset)
findings += Finding(abs, extractMessageFromTypo(t) ?: "Spelling/Grammar")
}
}
} catch (e: Throwable) {
log.info("LyngGrazieAnnotator: Grazie check failed: ${e.javaClass.simpleName}: ${e.message}")
}
}
log.info("LyngGrazieAnnotator.apply: used=${chosenEntry ?: "<none>"}, totalFindings=$totalReturned, painting=${findings.size}")
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<Collection<Any>?, 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()) "<none>" else sig}")
}
checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") }?.let { m ->
@Suppress("UNCHECKED_CAST")
val res = m.invoke(null, content) as? Collection<Any>
return res to "GrammarChecker.check(TextContent) static"
}
// 2) GrammarChecker.getInstance().check(TextContent)
val getInstance = checkerCls.methods.firstOrNull { it.name == "getInstance" && it.parameterCount == 0 }
val inst = getInstance?.invoke(null)
if (inst != null) {
val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") }
if (m != null) {
@Suppress("UNCHECKED_CAST")
val res = m.invoke(inst, content) as? Collection<Any>
return res to "GrammarChecker.getInstance().check(TextContent)"
}
}
// 3) GrammarChecker.getDefault().check(TextContent)
val getDefault = checkerCls.methods.firstOrNull { it.name == "getDefault" && it.parameterCount == 0 }
val def = getDefault?.invoke(null)
if (def != null) {
val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") }
if (m != null) {
@Suppress("UNCHECKED_CAST")
val res = m.invoke(def, content) as? Collection<Any>
return res to "GrammarChecker.getDefault().check(TextContent)"
}
}
// 4) Service from project/application: GrammarChecker as a service
runCatching {
val app = com.intellij.openapi.application.ApplicationManager.getApplication()
val getService = app::class.java.methods.firstOrNull { it.name == "getService" && it.parameterCount == 1 }
val svc = getService?.invoke(app, checkerCls)
if (svc != null) {
val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") }
if (m != null) {
@Suppress("UNCHECKED_CAST")
val res = m.invoke(svc, content) as? Collection<Any>
if (res != null) return res to "Application.getService(GrammarChecker).check(TextContent)"
}
}
}
runCatching {
val getService = file.project::class.java.methods.firstOrNull { it.name == "getService" && it.parameterCount == 1 }
val svc = getService?.invoke(file.project, checkerCls)
if (svc != null) {
val m = checkerCls.methods.firstOrNull { it.name == "check" && it.parameterCount == 1 && it.parameterTypes[0].name.endsWith("TextContent") }
if (m != null) {
@Suppress("UNCHECKED_CAST")
val res = m.invoke(svc, content) as? Collection<Any>
if (res != null) return res to "Project.getService(GrammarChecker).check(TextContent)"
}
}
}
}
// 5) Fallback: search any public method named check that accepts TextContent in any Grazie class (static)
val candidateClasses = listOf(
"com.intellij.grazie.grammar.GrammarChecker",
"com.intellij.grazie.grammar.GrammarRunner",
"com.intellij.grazie.grammar.Grammar" // historical names
)
for (cn in candidateClasses) {
val cls = try { Class.forName(cn) } catch (_: Throwable) { continue }
val m = cls.methods.firstOrNull { it.name == "check" && it.parameterTypes.any { p -> p.name.endsWith("TextContent") } }
if (m != null) {
val args = arrayOfNulls<Any>(m.parameterCount)
// place content to the first TextContent parameter; others left null (common defaults)
for (i in 0 until m.parameterCount) if (m.parameterTypes[i].name.endsWith("TextContent")) { args[i] = content; break }
@Suppress("UNCHECKED_CAST")
val res = m.invoke(null, *args) as? Collection<Any>
if (res != null) return res to "$cn.${m.name}(TextContent)"
}
}
// 6) Kotlin top-level function: GrammarCheckerKt.check(TextContent)
runCatching {
val kt = Class.forName("com.intellij.grazie.grammar.GrammarCheckerKt")
val m = kt.methods.firstOrNull { it.name == "check" && it.parameterTypes.any { p -> p.name.endsWith("TextContent") } }
if (m != null) {
val args = arrayOfNulls<Any>(m.parameterCount)
for (i in 0 until m.parameterCount) if (m.parameterTypes[i].name.endsWith("TextContent")) { args[i] = content; break }
@Suppress("UNCHECKED_CAST")
val res = m.invoke(null, *args) as? Collection<Any>
if (res != null) return res to "GrammarCheckerKt.check(TextContent)"
}
}
} catch (e: Throwable) {
log.info("LyngGrazieAnnotator: runGrazieChecks reflection failed: ${e.javaClass.simpleName}: ${e.message}")
}
return null to null
}
private fun extractRangeFromTypo(typo: Any): TextRange? {
// Try to get a relative range from returned Grazie issue/typo via common accessors
return try {
// Common getters
val m1 = typo.javaClass.methods.firstOrNull { it.name == "getRange" && it.parameterCount == 0 }
val r1 = if (m1 != null) m1.invoke(typo) else null
when (r1) {
is TextRange -> return r1
is IntRange -> return TextRange(r1.first, r1.last + 1)
}
val m2 = typo.javaClass.methods.firstOrNull { it.name == "getHighlightRange" && it.parameterCount == 0 }
val r2 = if (m2 != null) m2.invoke(typo) else null
when (r2) {
is TextRange -> return r2
is IntRange -> return TextRange(r2.first, r2.last + 1)
}
// Separate from/to ints
val fromM = typo.javaClass.methods.firstOrNull { it.name == "getFrom" && it.parameterCount == 0 && it.returnType == Int::class.javaPrimitiveType }
val toM = typo.javaClass.methods.firstOrNull { it.name == "getTo" && it.parameterCount == 0 && it.returnType == Int::class.javaPrimitiveType }
if (fromM != null && toM != null) {
val s = (fromM.invoke(typo) as? Int) ?: return null
val e = (toM.invoke(typo) as? Int) ?: return null
if (e > s) return TextRange(s, e)
}
null
} catch (_: Throwable) { null }
}
private fun extractMessageFromTypo(typo: Any): String? {
return try {
val m = typo.javaClass.methods.firstOrNull { it.name == "getMessage" && it.parameterCount == 0 }
(m?.invoke(typo) as? String)
} catch (_: Throwable) { null }
}
// Fallback that uses legacy SpellCheckerManager (if present) via reflection to validate words in fragments.
// Returns number of warnings painted.
private fun fallbackWithLegacySpellcheckerIfAvailable(
file: PsiFile,
fragments: List<Pair<TextContent, TextRange>>,
holder: AnnotationHolder,
existingFindings: List<Finding>
): 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<Pair<TextContent, TextRange>>,
holder: AnnotationHolder,
existingFindings: List<Finding>
): 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<String> {
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<String>
} else null
} catch (_: Throwable) {
null
} ?: emptyList()
// Merge with priority: project (p=0), spellchecker (p=1)
val all = LinkedHashSet<String>()
// 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<String> {
// Simple approach: use current file text; can be extended to project scanning later
val text = file.viewProvider.document?.text ?: return emptySet()
val out = LinkedHashSet<String>()
val tokenRegex = Regex("[A-Za-z][A-Za-z0-9_']{2,}")
for (m in tokenRegex.findAll(text)) {
val parts = splitIdentifier(m.value)
parts.forEach { out += it.lowercase() }
}
// Include learned words
val settings = LyngFormatterSettings.getInstance(file.project)
out.addAll(settings.learnedWords.map { it.lowercase() })
return out
}
private fun splitIdentifier(token: String): List<String> {
// Split on underscores and camelCase boundaries
val unders = token.split('_').filter { it.isNotBlank() }
val out = mutableListOf<String>()
val camelBoundary = Regex("(?<=[a-z])(?=[A-Z])")
for (u in unders) out += u.split(camelBoundary).filter { it.isNotBlank() }
return out
}
private fun isAllowedWord(w: String, learnedWords: Set<String> = 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]
}
}

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,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<TextRange>): Boolean = r != null && list.any { it.intersects(r) }
return when (type) {
LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT -> TextDomain.COMMENTS
LyngTokenTypes.STRING -> if (settings.grazieTreatLiteralsAsComments) TextDomain.COMMENTS else TextDomain.LITERALS
LyngTokenTypes.IDENTIFIER -> {
// For Grazie-only reliability in 243, route identifiers via COMMENTS when configured
if (settings.grazieTreatIdentifiersAsComments && index != null && r != null && overlaps(index.identifiers))
// 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
}
}

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.
@ -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<com.intellij.openapi.util.TextRange>): Boolean = r != null && list.any { it.intersects(r) }
// Decide target domain by intersection with our MiniAst-driven index; prefer comments > strings > identifiers
var domain: TextDomain? = null
if (index != null && r != null) {
if (overlaps(index.comments)) domain = TextDomain.COMMENTS
else if (overlaps(index.strings) && settings.spellCheckStringLiterals) domain = TextDomain.LITERALS
else if (overlaps(index.identifiers)) domain = if (settings.grazieTreatIdentifiersAsComments) TextDomain.COMMENTS else TextDomain.DOCUMENTATION
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) {

View File

@ -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<PsiElement>() {
override fun tokenize(element: PsiElement, consumer: TokenConsumer) {}
}
private object IDENTIFIER_TOKENIZER : Tokenizer<PsiElement>() {
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<PsiElement>() {
private val splitter = PlainTextSplitter.getInstance()
override fun tokenize(element: PsiElement, consumer: TokenConsumer) {
val text = element.text
if (text.isNullOrEmpty()) return
consumer.consumeToken(element, text, false, 0, TextRange(0, text.length), splitter)
}
}
private object STRING_WITH_PRINTF_EXCLUDES : Tokenizer<PsiElement>() {
private val splitter = PlainTextSplitter.getInstance()
// Regex for printf-style specifiers: %[flags][width][.precision][length]type
// This is intentionally permissive to skip common cases like %s, %d, %-12s, %08x, %.2f, %%
private val SPEC = Regex("%(?:[-+ #0]*(?:\\d+)?(?:\\.\\d+)?[a-zA-Z%])")
override fun tokenize(element: PsiElement, consumer: TokenConsumer) {
// Check project settings whether literals should be spell-checked
val settings = LyngFormatterSettings.getInstance(element.project)
if (!settings.spellCheckStringLiterals) return
val text = element.text
if (text.isEmpty()) return
// Try to strip surrounding quotes (simple lexer token for Lyng strings)
var startOffsetInElement = 0
var endOffsetInElement = text.length
if (text.length >= 2 && (text.first() == '"' && text.last() == '"' || text.first() == '\'' && text.last() == '\'')) {
startOffsetInElement = 1
endOffsetInElement = text.length - 1
}
if (endOffsetInElement <= startOffsetInElement) return
val content = text.substring(startOffsetInElement, endOffsetInElement)
var last = 0
for (m in SPEC.findAll(content)) {
val ms = m.range.first
val me = m.range.last + 1
if (ms > last) {
val range = TextRange(startOffsetInElement + last, startOffsetInElement + ms)
consumer.consumeToken(element, text, false, 0, range, splitter)
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)
}
}
}

View File

@ -57,9 +57,6 @@
<!-- External annotator for semantic highlighting -->
<externalAnnotator language="Lyng" implementationClass="net.sergeych.lyng.idea.annotators.LyngExternalAnnotator"/>
<!-- Grazie-backed spell/grammar annotator (runs only when Grazie is installed) -->
<externalAnnotator language="Lyng" implementationClass="net.sergeych.lyng.idea.grazie.LyngGrazieAnnotator"/>
<!-- Quick documentation provider bound to Lyng language -->
<lang.documentationProvider language="Lyng" implementationClass="net.sergeych.lyng.idea.docs.LyngDocumentationProvider"/>
@ -105,5 +102,15 @@
</extensions>
<actions/>
<actions>
<action id="net.sergeych.lyng.idea.actions.RunLyngScriptAction"
class="net.sergeych.lyng.idea.actions.RunLyngScriptAction"
text="Run Lyng Script"
description="Run the current Lyng script and show output in console">
<add-to-group group-id="EditorPopupMenu" anchor="last"/>
<add-to-group group-id="ProjectViewPopupMenu" anchor="last"/>
<add-to-group group-id="RunMenu" anchor="last"/>
<keyboard-shortcut keymap="$default" first-keystroke="control shift F10"/>
</action>
</actions>
</idea-plugin>

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.
@ -22,8 +22,7 @@
-->
<idea-plugin>
<extensions defaultExtensionNs="com.intellij">
<!-- Spellchecker strategy: identifiers + comments; literals configurable, skipping printf-like specs -->
<spellchecker.support language="Lyng"
implementationClass="net.sergeych.lyng.idea.spell.LyngSpellcheckingStrategy"/>
implementationClass="net.sergeych.lyng.idea.spell.LyngSpellcheckingStrategy"/>
</extensions>
</idea-plugin>

View File

@ -1881,6 +1881,7 @@ class Compiler(
pendingDeclStart = null
// so far only simplest enums:
val names = mutableListOf<String>()
val positions = mutableListOf<Pos>()
// 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
)
)

View File

@ -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",

View File

@ -205,6 +205,7 @@ data class MiniEnumDecl(
override val nameStart: Pos,
override val isExtern: Boolean = false,
override val isStatic: Boolean = false,
val entryPositions: List<Pos> = emptyList()
) : MiniDecl
data class MiniCtorField(

View File

@ -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)

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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","""

View File

@ -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