plugin: run command. Lyng string "hello"*repeatCount operator. Plugin spell check is still not working properly
This commit is contained in:
parent
2d2a74656c
commit
6fa57c8197
@ -1252,7 +1252,7 @@ The same with `--`:
|
|||||||
sum
|
sum
|
||||||
>>> 5050
|
>>> 5050
|
||||||
|
|
||||||
There are self-assigning version for operators too:
|
There is a self-assigning version for operators too:
|
||||||
|
|
||||||
var count = 100
|
var count = 100
|
||||||
var sum = 0
|
var sum = 0
|
||||||
@ -1471,7 +1471,13 @@ Part match:
|
|||||||
assert( "foo" == $~.value )
|
assert( "foo" == $~.value )
|
||||||
>>> void
|
>>> 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 |
|
| fun/prop | description / notes |
|
||||||
|----------------------|------------------------------------------------------------|
|
|----------------------|------------------------------------------------------------|
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -318,8 +318,62 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map Enum constants from token highlighter to IDEA enum constant color
|
// Build spell index payload: identifiers + comments/strings from simple highlighter.
|
||||||
run {
|
// We limit identifier checks to declarations (val, var, fun, class, enum) and enum constants.
|
||||||
|
val spellIds = ArrayList<IntRange>()
|
||||||
|
fun addSpellId(pos: net.sergeych.lyng.Pos, name: String) {
|
||||||
|
if (pos.source == source) {
|
||||||
|
val s = source.offsetOf(pos)
|
||||||
|
val e = (s + name.length).coerceAtMost(text.length)
|
||||||
|
if (s < e) spellIds.add(s until e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add declarations from MiniAst
|
||||||
|
mini.declarations.forEach { d ->
|
||||||
|
addSpellId(d.nameStart, d.name)
|
||||||
|
when (d) {
|
||||||
|
is MiniFunDecl -> {
|
||||||
|
d.params.forEach { addSpellId(it.nameStart, it.name) }
|
||||||
|
addTypeNames(d.returnType, ::addSpellId)
|
||||||
|
addTypeNames(d.receiver, ::addSpellId)
|
||||||
|
}
|
||||||
|
is MiniValDecl -> {
|
||||||
|
addTypeNames(d.type, ::addSpellId)
|
||||||
|
addTypeNames(d.receiver, ::addSpellId)
|
||||||
|
}
|
||||||
|
is MiniEnumDecl -> {
|
||||||
|
if (d.entries.size == d.entryPositions.size) {
|
||||||
|
for (i in d.entries.indices) {
|
||||||
|
addSpellId(d.entryPositions[i], d.entries[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MiniClassDecl -> {
|
||||||
|
d.ctorFields.forEach { addSpellId(it.nameStart, it.name) }
|
||||||
|
d.classFields.forEach { addSpellId(it.nameStart, it.name) }
|
||||||
|
d.members.forEach { m ->
|
||||||
|
when (m) {
|
||||||
|
is MiniMemberFunDecl -> {
|
||||||
|
addSpellId(m.nameStart, m.name)
|
||||||
|
m.params.forEach { addSpellId(it.nameStart, it.name) }
|
||||||
|
addTypeNames(m.returnType, ::addSpellId)
|
||||||
|
}
|
||||||
|
is MiniMemberValDecl -> {
|
||||||
|
addSpellId(m.nameStart, m.name)
|
||||||
|
addTypeNames(m.type, ::addSpellId)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map Enum constants from token highlighter for highlighting only.
|
||||||
|
// We do NOT add them to spellIds here because they might be usages,
|
||||||
|
// and declarations are already handled via MiniEnumDecl above.
|
||||||
tokens.forEach { s ->
|
tokens.forEach { s ->
|
||||||
if (s.kind == HighlightKind.EnumConstant) {
|
if (s.kind == HighlightKind.EnumConstant) {
|
||||||
val start = s.range.start
|
val start = s.range.start
|
||||||
@ -329,21 +383,42 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 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 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 }
|
val stringRanges = tokens.filter { it.kind == HighlightKind.String }.map { it.range.start until it.range.endExclusive }
|
||||||
|
|
||||||
return Result(collectedInfo.modStamp, out, null,
|
return Result(collectedInfo.modStamp, out, null,
|
||||||
spellIdentifiers = idRanges.toList(),
|
spellIdentifiers = spellIds,
|
||||||
spellComments = commentRanges,
|
spellComments = commentRanges,
|
||||||
spellStrings = stringRanges)
|
spellStrings = stringRanges)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to add all segments of a type name to the spell index.
|
||||||
|
*/
|
||||||
|
private fun addTypeNames(t: MiniTypeRef?, add: (net.sergeych.lyng.Pos, String) -> Unit) {
|
||||||
|
when (t) {
|
||||||
|
is MiniTypeName -> t.segments.forEach { add(it.range.start, it.name) }
|
||||||
|
is MiniGenericType -> {
|
||||||
|
addTypeNames(t.base, add)
|
||||||
|
t.args.forEach { addTypeNames(it, add) }
|
||||||
|
}
|
||||||
|
|
||||||
|
is MiniFunctionType -> {
|
||||||
|
addTypeNames(t.receiver, add)
|
||||||
|
t.params.forEach { addTypeNames(it, add) }
|
||||||
|
addTypeNames(t.returnType, add)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MiniTypeVar -> {
|
||||||
|
// Type variables are declarations too
|
||||||
|
add(t.range.start, t.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun apply(file: PsiFile, annotationResult: Result?, holder: AnnotationHolder) {
|
override fun apply(file: PsiFile, annotationResult: Result?, holder: AnnotationHolder) {
|
||||||
if (annotationResult == null) return
|
if (annotationResult == null) return
|
||||||
// Skip if cache is up-to-date
|
// Skip if cache is up-to-date
|
||||||
|
|||||||
@ -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]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
@ -21,7 +21,6 @@ import com.intellij.grazie.grammar.strategy.GrammarCheckingStrategy.TextDomain
|
|||||||
import com.intellij.ide.plugins.PluginManagerCore
|
import com.intellij.ide.plugins.PluginManagerCore
|
||||||
import com.intellij.openapi.diagnostic.Logger
|
import com.intellij.openapi.diagnostic.Logger
|
||||||
import com.intellij.openapi.extensions.PluginId
|
import com.intellij.openapi.extensions.PluginId
|
||||||
import com.intellij.openapi.util.TextRange
|
|
||||||
import com.intellij.psi.PsiElement
|
import com.intellij.psi.PsiElement
|
||||||
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
|
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
|
||||||
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
|
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 index = if (file != null) LyngSpellIndex.getUpToDate(file) else null
|
||||||
val r = root.textRange
|
val r = root.textRange
|
||||||
|
|
||||||
fun overlaps(list: List<TextRange>): Boolean = r != null && list.any { it.intersects(r) }
|
|
||||||
|
|
||||||
return when (type) {
|
return when (type) {
|
||||||
LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT -> TextDomain.COMMENTS
|
LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT -> TextDomain.COMMENTS
|
||||||
LyngTokenTypes.STRING -> if (settings.grazieTreatLiteralsAsComments) TextDomain.COMMENTS else TextDomain.LITERALS
|
LyngTokenTypes.STRING -> if (settings.grazieTreatLiteralsAsComments) TextDomain.COMMENTS else TextDomain.LITERALS
|
||||||
LyngTokenTypes.IDENTIFIER -> {
|
LyngTokenTypes.IDENTIFIER -> {
|
||||||
// For Grazie-only reliability in 243, route identifiers via COMMENTS when configured
|
// For Grazie-only reliability in 243+, route identifiers via COMMENTS when configured
|
||||||
if (settings.grazieTreatIdentifiersAsComments && index != null && r != null && overlaps(index.identifiers))
|
if (settings.grazieTreatIdentifiersAsComments && index != null && r != null && index.identifiers.any { it.contains(r) })
|
||||||
TextDomain.COMMENTS
|
TextDomain.COMMENTS
|
||||||
else TextDomain.PLAIN_TEXT
|
else TextDomain.PLAIN_TEXT
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> TextDomain.PLAIN_TEXT
|
else -> TextDomain.PLAIN_TEXT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
@ -46,14 +46,12 @@ class LyngTextExtractor : TextExtractor() {
|
|||||||
val index = if (file != null) LyngSpellIndex.getUpToDate(file) else null
|
val index = if (file != null) LyngSpellIndex.getUpToDate(file) else null
|
||||||
val r = element.textRange
|
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
|
// Decide target domain by intersection with our MiniAst-driven index; prefer comments > strings > identifiers
|
||||||
var domain: TextDomain? = null
|
var domain: TextDomain? = null
|
||||||
if (index != null && r != null) {
|
if (index != null && r != null) {
|
||||||
if (overlaps(index.comments)) domain = TextDomain.COMMENTS
|
if (index.comments.any { it.intersects(r) }) domain = TextDomain.COMMENTS
|
||||||
else if (overlaps(index.strings) && settings.spellCheckStringLiterals) domain = TextDomain.LITERALS
|
else if (index.strings.any { it.intersects(r) } && settings.spellCheckStringLiterals) domain = TextDomain.LITERALS
|
||||||
else if (overlaps(index.identifiers)) domain = if (settings.grazieTreatIdentifiersAsComments) TextDomain.COMMENTS else TextDomain.DOCUMENTATION
|
else if (index.identifiers.any { it.contains(r) }) domain = if (settings.grazieTreatIdentifiersAsComments) TextDomain.COMMENTS else TextDomain.DOCUMENTATION
|
||||||
} else {
|
} else {
|
||||||
// Fallback to token type if index is not ready (rare timing), mostly for comments
|
// Fallback to token type if index is not ready (rare timing), mostly for comments
|
||||||
domain = when (type) {
|
domain = when (type) {
|
||||||
|
|||||||
@ -16,106 +16,37 @@
|
|||||||
*/
|
*/
|
||||||
package net.sergeych.lyng.idea.spell
|
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.psi.PsiElement
|
||||||
import com.intellij.spellchecker.inspections.PlainTextSplitter
|
|
||||||
import com.intellij.spellchecker.tokenizer.SpellcheckingStrategy
|
import com.intellij.spellchecker.tokenizer.SpellcheckingStrategy
|
||||||
import com.intellij.spellchecker.tokenizer.TokenConsumer
|
|
||||||
import com.intellij.spellchecker.tokenizer.Tokenizer
|
import com.intellij.spellchecker.tokenizer.Tokenizer
|
||||||
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
|
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spellchecking strategy for Lyng:
|
* Standard IntelliJ spellchecking strategy for Lyng.
|
||||||
* - Identifiers: checked as identifiers
|
* It uses the MiniAst-driven [LyngSpellIndex] to limit identifier checks to declarations only.
|
||||||
* - Comments: checked as plain text
|
|
||||||
* - Keywords: skipped
|
|
||||||
* - String literals: optional (controlled by settings), and we exclude printf-style format specifiers like
|
|
||||||
* %s, %d, %-12s, %0.2f, etc.
|
|
||||||
*/
|
*/
|
||||||
class LyngSpellcheckingStrategy : SpellcheckingStrategy() {
|
class LyngSpellcheckingStrategy : SpellcheckingStrategy() {
|
||||||
|
override fun getTokenizer(element: PsiElement?): Tokenizer<*> {
|
||||||
override fun getTokenizer(element: PsiElement): Tokenizer<*> {
|
val type = element?.node?.elementType
|
||||||
if (element is com.intellij.psi.PsiFile) return EMPTY_TOKENIZER
|
return when (type) {
|
||||||
|
LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT -> TEXT_TOKENIZER
|
||||||
val settings = LyngFormatterSettings.getInstance(element.project)
|
LyngTokenTypes.STRING -> TEXT_TOKENIZER
|
||||||
val et = element.node?.elementType
|
LyngTokenTypes.IDENTIFIER -> {
|
||||||
|
// We use standard NameIdentifierOwner/PsiNamedElement-based logic
|
||||||
if (et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.IDENTIFIER || et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.LABEL) {
|
// if it's a declaration. Argument names, class names, etc. are PSI-based.
|
||||||
return IDENTIFIER_TOKENIZER
|
// However, our PSI is currently very minimal (ASTWrapperPsiElement).
|
||||||
}
|
// So we stick to the index but ensure it is robustly filled.
|
||||||
if (et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.LINE_COMMENT || et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.BLOCK_COMMENT) {
|
val file = element.containingFile
|
||||||
return COMMENT_TEXT_TOKENIZER
|
val index = LyngSpellIndex.getUpToDate(file)
|
||||||
}
|
if (index != null) {
|
||||||
if (et == net.sergeych.lyng.idea.highlight.LyngTokenTypes.STRING && settings.spellCheckStringLiterals) {
|
val range = element.textRange
|
||||||
return STRING_WITH_PRINTF_EXCLUDES
|
if (index.identifiers.any { it.contains(range) }) {
|
||||||
}
|
return TEXT_TOKENIZER
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
EMPTY_TOKENIZER
|
||||||
private object COMMENT_TEXT_TOKENIZER : Tokenizer<PsiElement>() {
|
|
||||||
private val splitter = PlainTextSplitter.getInstance()
|
|
||||||
override fun tokenize(element: PsiElement, consumer: TokenConsumer) {
|
|
||||||
val text = element.text
|
|
||||||
if (text.isNullOrEmpty()) return
|
|
||||||
consumer.consumeToken(element, text, false, 0, TextRange(0, text.length), splitter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private object STRING_WITH_PRINTF_EXCLUDES : Tokenizer<PsiElement>() {
|
|
||||||
private val splitter = PlainTextSplitter.getInstance()
|
|
||||||
|
|
||||||
// Regex for printf-style specifiers: %[flags][width][.precision][length]type
|
|
||||||
// This is intentionally permissive to skip common cases like %s, %d, %-12s, %08x, %.2f, %%
|
|
||||||
private val SPEC = Regex("%(?:[-+ #0]*(?:\\d+)?(?:\\.\\d+)?[a-zA-Z%])")
|
|
||||||
|
|
||||||
override fun tokenize(element: PsiElement, consumer: TokenConsumer) {
|
|
||||||
// Check project settings whether literals should be spell-checked
|
|
||||||
val settings = LyngFormatterSettings.getInstance(element.project)
|
|
||||||
if (!settings.spellCheckStringLiterals) return
|
|
||||||
|
|
||||||
val text = element.text
|
|
||||||
if (text.isEmpty()) return
|
|
||||||
|
|
||||||
// Try to strip surrounding quotes (simple lexer token for Lyng strings)
|
|
||||||
var startOffsetInElement = 0
|
|
||||||
var endOffsetInElement = text.length
|
|
||||||
if (text.length >= 2 && (text.first() == '"' && text.last() == '"' || text.first() == '\'' && text.last() == '\'')) {
|
|
||||||
startOffsetInElement = 1
|
|
||||||
endOffsetInElement = text.length - 1
|
|
||||||
}
|
|
||||||
if (endOffsetInElement <= startOffsetInElement) return
|
|
||||||
|
|
||||||
val content = text.substring(startOffsetInElement, endOffsetInElement)
|
|
||||||
|
|
||||||
var last = 0
|
|
||||||
for (m in SPEC.findAll(content)) {
|
|
||||||
val ms = m.range.first
|
|
||||||
val me = m.range.last + 1
|
|
||||||
if (ms > last) {
|
|
||||||
val range = TextRange(startOffsetInElement + last, startOffsetInElement + ms)
|
|
||||||
consumer.consumeToken(element, text, false, 0, range, splitter)
|
|
||||||
}
|
|
||||||
last = me
|
|
||||||
}
|
|
||||||
if (last < content.length) {
|
|
||||||
val range = TextRange(startOffsetInElement + last, startOffsetInElement + content.length)
|
|
||||||
consumer.consumeToken(element, text, false, 0, range, splitter)
|
|
||||||
}
|
}
|
||||||
|
else -> super.getTokenizer(element)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,9 +57,6 @@
|
|||||||
<!-- External annotator for semantic highlighting -->
|
<!-- External annotator for semantic highlighting -->
|
||||||
<externalAnnotator language="Lyng" implementationClass="net.sergeych.lyng.idea.annotators.LyngExternalAnnotator"/>
|
<externalAnnotator language="Lyng" implementationClass="net.sergeych.lyng.idea.annotators.LyngExternalAnnotator"/>
|
||||||
|
|
||||||
<!-- Grazie-backed spell/grammar annotator (runs only when Grazie is installed) -->
|
|
||||||
<externalAnnotator language="Lyng" implementationClass="net.sergeych.lyng.idea.grazie.LyngGrazieAnnotator"/>
|
|
||||||
|
|
||||||
<!-- Quick documentation provider bound to Lyng language -->
|
<!-- Quick documentation provider bound to Lyng language -->
|
||||||
<lang.documentationProvider language="Lyng" implementationClass="net.sergeych.lyng.idea.docs.LyngDocumentationProvider"/>
|
<lang.documentationProvider language="Lyng" implementationClass="net.sergeych.lyng.idea.docs.LyngDocumentationProvider"/>
|
||||||
|
|
||||||
@ -105,5 +102,15 @@
|
|||||||
|
|
||||||
</extensions>
|
</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>
|
</idea-plugin>
|
||||||
|
|||||||
@ -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");
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
~ you may not use this file except in compliance with the License.
|
~ you may not use this file except in compliance with the License.
|
||||||
@ -22,7 +22,6 @@
|
|||||||
-->
|
-->
|
||||||
<idea-plugin>
|
<idea-plugin>
|
||||||
<extensions defaultExtensionNs="com.intellij">
|
<extensions defaultExtensionNs="com.intellij">
|
||||||
<!-- Spellchecker strategy: identifiers + comments; literals configurable, skipping printf-like specs -->
|
|
||||||
<spellchecker.support language="Lyng"
|
<spellchecker.support language="Lyng"
|
||||||
implementationClass="net.sergeych.lyng.idea.spell.LyngSpellcheckingStrategy"/>
|
implementationClass="net.sergeych.lyng.idea.spell.LyngSpellcheckingStrategy"/>
|
||||||
</extensions>
|
</extensions>
|
||||||
|
|||||||
@ -1881,6 +1881,7 @@ class Compiler(
|
|||||||
pendingDeclStart = null
|
pendingDeclStart = null
|
||||||
// so far only simplest enums:
|
// so far only simplest enums:
|
||||||
val names = mutableListOf<String>()
|
val names = mutableListOf<String>()
|
||||||
|
val positions = mutableListOf<Pos>()
|
||||||
// skip '{'
|
// skip '{'
|
||||||
cc.skipTokenOfType(Token.Type.LBRACE)
|
cc.skipTokenOfType(Token.Type.LBRACE)
|
||||||
|
|
||||||
@ -1889,6 +1890,7 @@ class Compiler(
|
|||||||
when (t.type) {
|
when (t.type) {
|
||||||
Token.Type.ID -> {
|
Token.Type.ID -> {
|
||||||
names += t.value
|
names += t.value
|
||||||
|
positions += t.pos
|
||||||
val t1 = cc.nextNonWhitespace()
|
val t1 = cc.nextNonWhitespace()
|
||||||
when (t1.type) {
|
when (t1.type) {
|
||||||
Token.Type.COMMA ->
|
Token.Type.COMMA ->
|
||||||
@ -1912,7 +1914,8 @@ class Compiler(
|
|||||||
entries = names,
|
entries = names,
|
||||||
doc = doc,
|
doc = doc,
|
||||||
nameStart = nameToken.pos,
|
nameStart = nameToken.pos,
|
||||||
isExtern = isExtern
|
isExtern = isExtern,
|
||||||
|
entryPositions = positions
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@ private val fallbackKeywordIds = setOf(
|
|||||||
"and", "or", "not",
|
"and", "or", "not",
|
||||||
// declarations & modifiers
|
// declarations & modifiers
|
||||||
"fun", "fn", "class", "interface", "enum", "val", "var", "import", "package",
|
"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",
|
"private", "protected", "static", "open", "extern", "init", "get", "set", "by",
|
||||||
// control flow and misc
|
// control flow and misc
|
||||||
"if", "else", "when", "while", "do", "for", "try", "catch", "finally",
|
"if", "else", "when", "while", "do", "for", "try", "catch", "finally",
|
||||||
|
|||||||
@ -205,6 +205,7 @@ data class MiniEnumDecl(
|
|||||||
override val nameStart: Pos,
|
override val nameStart: Pos,
|
||||||
override val isExtern: Boolean = false,
|
override val isExtern: Boolean = false,
|
||||||
override val isStatic: Boolean = false,
|
override val isStatic: Boolean = false,
|
||||||
|
val entryPositions: List<Pos> = emptyList()
|
||||||
) : MiniDecl
|
) : MiniDecl
|
||||||
|
|
||||||
data class MiniCtorField(
|
data class MiniCtorField(
|
||||||
|
|||||||
@ -72,10 +72,11 @@ open class ObjException(
|
|||||||
val l = getStackTrace().list
|
val l = getStackTrace().list
|
||||||
return buildString {
|
return buildString {
|
||||||
append(message.value)
|
append(message.value)
|
||||||
for( t in l)
|
for (t in l)
|
||||||
append("\n\tat ${t.toString(scope)}")
|
append("\n\tat ${t.toString(scope)}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun defaultToString(scope: Scope): ObjString {
|
override suspend fun defaultToString(scope: Scope): ObjString {
|
||||||
val at = getStackTrace().list.firstOrNull()?.toString(scope)
|
val at = getStackTrace().list.firstOrNull()?.toString(scope)
|
||||||
?: ObjString("(unknown)")
|
?: ObjString("(unknown)")
|
||||||
@ -100,6 +101,7 @@ open class ObjException(
|
|||||||
while (s != null) {
|
while (s != null) {
|
||||||
val pos = s.pos
|
val pos = s.pos
|
||||||
if (pos != lastPos && !pos.currentLine.isEmpty()) {
|
if (pos != lastPos && !pos.currentLine.isEmpty()) {
|
||||||
|
if( (lastPos == null || (lastPos.source != pos.source || lastPos.line != pos.line)) ) {
|
||||||
if (maybeCls != null) {
|
if (maybeCls != null) {
|
||||||
result.list += maybeCls.callWithArgs(
|
result.list += maybeCls.callWithArgs(
|
||||||
scope,
|
scope,
|
||||||
@ -110,11 +112,12 @@ open class ObjException(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Fallback textual entry if StackTraceEntry class is not available in this scope
|
// Fallback textual entry if StackTraceEntry class is not available in this scope
|
||||||
result.list += ObjString("${pos.source.objSourceName}:${pos.line}:${pos.column}: ${pos.currentLine}")
|
result.list += ObjString("?${pos.source.objSourceName}:${pos.line+1}:${pos.column+1}: ${pos.currentLine}")
|
||||||
|
}
|
||||||
|
lastPos = pos
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s = s.parent
|
s = s.parent
|
||||||
lastPos = pos
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@ -339,8 +342,8 @@ fun Obj.isLyngException(): Boolean = isInstanceOf("Exception")
|
|||||||
/**
|
/**
|
||||||
* Get the exception message.
|
* Get the exception message.
|
||||||
*/
|
*/
|
||||||
suspend fun Obj.getLyngExceptionMessage(scope: Scope?=null): String {
|
suspend fun Obj.getLyngExceptionMessage(scope: Scope? = null): String {
|
||||||
require( this.isLyngException() )
|
require(this.isLyngException())
|
||||||
val s = scope ?: Script.newScope()
|
val s = scope ?: Script.newScope()
|
||||||
return invokeInstanceMethod(s, "message").toString(s).value
|
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.
|
* The stack trace details each frame using indentation for clarity.
|
||||||
* @throws IllegalArgumentException if the object is not a Lyng exception.
|
* @throws IllegalArgumentException if the object is not a Lyng exception.
|
||||||
*/
|
*/
|
||||||
suspend fun Obj.getLyngExceptionMessageWithStackTrace(scope: Scope?=null): String {
|
suspend fun Obj.getLyngExceptionMessageWithStackTrace(scope: Scope? = null,showDetails:Boolean=true): String {
|
||||||
require( this.isLyngException() )
|
require(this.isLyngException())
|
||||||
val s = scope ?: Script.newScope()
|
val s = scope ?: Script.newScope()
|
||||||
val msg = getLyngExceptionMessage(s)
|
val msg = getLyngExceptionMessage(s)
|
||||||
val trace = getLyngExceptionStackTrace(s)
|
val trace = getLyngExceptionStackTrace(s)
|
||||||
var at = "unknown"
|
var at = "unknown"
|
||||||
val stack = if( !trace.list.isEmpty() ) {
|
// var firstLine = true
|
||||||
|
val stack = if (!trace.list.isEmpty()) {
|
||||||
val first = trace.list[0]
|
val first = trace.list[0]
|
||||||
at = (first.readField(s, "at").value as ObjString).value
|
at = (first.readField(s, "at").value as ObjString).value
|
||||||
"\n" + trace.list.map { " at " + it.toString(s).value }.joinToString("\n")
|
"\n" + trace.list.map { " at " + it.toString(s).value }.joinToString("\n")
|
||||||
}
|
} else ""
|
||||||
else ""
|
|
||||||
return "$at: $msg$stack"
|
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.
|
* 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()
|
if (this is ObjException) raise()
|
||||||
val sc = scope ?: Script.newScope()
|
val sc = scope ?: Script.newScope()
|
||||||
val msg = getLyngExceptionMessage(sc)
|
val msg = getLyngExceptionMessage(sc)
|
||||||
|
|||||||
@ -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 {
|
override fun hashCode(): Int {
|
||||||
return value.hashCode()
|
return value.hashCode()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -168,6 +168,7 @@ class MiniAstTest {
|
|||||||
assertNotNull(ed.doc)
|
assertNotNull(ed.doc)
|
||||||
assertTrue(ed.doc.raw.contains("Enum E docs"))
|
assertTrue(ed.doc.raw.contains("Enum E docs"))
|
||||||
assertEquals(listOf("A", "B", "C"), ed.entries)
|
assertEquals(listOf("A", "B", "C"), ed.entries)
|
||||||
|
assertEquals(3, ed.entryPositions.size)
|
||||||
assertEquals("E", ed.name)
|
assertEquals("E", ed.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4160,6 +4160,14 @@ class ScriptTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testStringMul() = runTest {
|
||||||
|
eval("""
|
||||||
|
assertEquals("hellohello", "hello"*2)
|
||||||
|
assertEquals("", "hello"*0)
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testLogicalNot() = runTest {
|
fun testLogicalNot() = runTest {
|
||||||
eval(
|
eval(
|
||||||
@ -4672,6 +4680,29 @@ class ScriptTest {
|
|||||||
// source name, in our case, is is "tc2":
|
// source name, in our case, is is "tc2":
|
||||||
assertContains(x1.message!!, "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
|
@Test
|
||||||
fun testLyngToKotlinExceptionHelpers() = runTest {
|
fun testLyngToKotlinExceptionHelpers() = runTest {
|
||||||
var x = evalNamed( "tc1","""
|
var x = evalNamed( "tc1","""
|
||||||
|
|||||||
@ -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 {
|
fun Iterable.count(predicate): Int {
|
||||||
var hits = 0
|
var hits = 0
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user