attempt to add navigation to plugin (partial success)

This commit is contained in:
sergeych committed 2026-01-03 11:59:50 +01:00
1 parent 59aefc5bc2
commit bc6613ec01
23 files changed
+1498 -966

No files matched your search

+2 -2
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,7 @@ plugins {
}
group = "net.sergeych.lyng"
version = "0.0.4-SNAPSHOT"
version = "0.0.5-SNAPSHOT"
kotlin {
jvmToolchain(17)
@@ -185,9 +185,9 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
fun keyForKind(k: SymbolKind) = when (k) {
SymbolKind.Function -> LyngHighlighterColors.FUNCTION
SymbolKind.Class, SymbolKind.Enum -> LyngHighlighterColors.TYPE
SymbolKind.Param -> LyngHighlighterColors.PARAMETER
SymbolKind.Val -> LyngHighlighterColors.VALUE
SymbolKind.Var -> LyngHighlighterColors.VARIABLE
SymbolKind.Parameter -> LyngHighlighterColors.PARAMETER
SymbolKind.Value -> LyngHighlighterColors.VALUE
SymbolKind.Variable -> LyngHighlighterColors.VARIABLE
}
// Track covered ranges to not override later heuristics
@@ -21,23 +21,21 @@
*/
package net.sergeych.lyng.idea.completion
import LyngAstManager
import com.intellij.codeInsight.completion.*
import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.icons.AllIcons
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Document
import com.intellij.openapi.util.Key
import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.PsiFile
import com.intellij.util.ProcessingContext
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Source
import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
import net.sergeych.lyng.idea.util.DocsBootstrap
import net.sergeych.lyng.idea.util.IdeLenientImportProvider
import net.sergeych.lyng.idea.util.TextCtx
import net.sergeych.lyng.miniast.*
@@ -65,6 +63,12 @@ class LyngCompletionContributor : CompletionContributor() {
StdlibDocsBootstrap.ensure()
val file: PsiFile = parameters.originalFile
if (file.language != LyngLanguage) return
// Disable completion inside comments
val pos = parameters.position
val et = pos.node.elementType
if (et == LyngTokenTypes.LINE_COMMENT || et == LyngTokenTypes.BLOCK_COMMENT) return
// Feature toggle: allow turning completion off from settings
val settings = LyngFormatterSettings.getInstance(file.project)
if (!settings.enableLyngCompletionExperimental) return
@@ -94,7 +98,8 @@ class LyngCompletionContributor : CompletionContributor() {
}
// Build MiniAst (cached) for both global and member contexts to enable local class/val inference
val mini = buildMiniAstCached(file, text)
val mini = LyngAstManager.getMiniAst(file)
val binding = LyngAstManager.getBinding(file)
// Delegate computation to the shared engine to keep behavior in sync with tests
val engineItems = try {
@@ -121,13 +126,13 @@ class LyngCompletionContributor : CompletionContributor() {
// Try inferring return/receiver class around the dot
val inferred =
// Prefer MiniAst-based inference (return type from member call or receiver type)
guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported)
?: guessReceiverClassViaMini(mini, text, memberDotPos, imported)
DocLookupUtils.guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported, binding)
?: DocLookupUtils.guessReceiverClassViaMini(mini, text, memberDotPos, imported, binding)
?:
guessReturnClassFromMemberCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported, mini)
?: guessReceiverClass(text, memberDotPos, imported, mini)
DocLookupUtils.guessReturnClassFromMemberCallBefore(text, memberDotPos, imported, mini)
?: DocLookupUtils.guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported, mini)
?: DocLookupUtils.guessReturnClassAcrossKnownCallees(text, memberDotPos, imported, mini)
?: DocLookupUtils.guessReceiverClass(text, memberDotPos, imported, mini)
if (inferred != null) {
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Fallback inferred receiver/return class='$inferred' — offering its members")
@@ -179,12 +184,12 @@ class LyngCompletionContributor : CompletionContributor() {
add("lyng.stdlib")
}.toList()
val inferredClass =
guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported)
?: guessReceiverClassViaMini(mini, text, memberDotPos, imported)
?: guessReturnClassFromMemberCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported, mini)
?: guessReceiverClass(text, memberDotPos, imported, mini)
DocLookupUtils.guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported, binding)
?: DocLookupUtils.guessReceiverClassViaMini(mini, text, memberDotPos, imported, binding)
?: DocLookupUtils.guessReturnClassFromMemberCallBefore(text, memberDotPos, imported, mini)
?: DocLookupUtils.guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported, mini)
?: DocLookupUtils.guessReturnClassAcrossKnownCallees(text, memberDotPos, imported, mini)
?: DocLookupUtils.guessReceiverClass(text, memberDotPos, imported, mini)
if (!inferredClass.isNullOrBlank()) {
val ext = BuiltinDocRegistry.extensionMemberNamesFor(inferredClass)
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Post-engine extension check for $inferredClass: ${'$'}{ext}")
@@ -234,12 +239,12 @@ class LyngCompletionContributor : CompletionContributor() {
add("lyng.stdlib")
}.toList()
val inferred =
guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported)
?: guessReceiverClassViaMini(mini, text, memberDotPos, imported)
?: guessReturnClassFromMemberCallBefore(text, memberDotPos, imported)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported)
?: guessReceiverClass(text, memberDotPos, imported)
DocLookupUtils.guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported, binding)
?: DocLookupUtils.guessReceiverClassViaMini(mini, text, memberDotPos, imported, binding)
?: DocLookupUtils.guessReturnClassFromMemberCallBefore(text, memberDotPos, imported, mini)
?: DocLookupUtils.guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported, mini)
?: DocLookupUtils.guessReturnClassAcrossKnownCallees(text, memberDotPos, imported, mini)
?: DocLookupUtils.guessReceiverClass(text, memberDotPos, imported, mini)
if (inferred != null) {
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Enrichment inferred class='$inferred' — offering its members")
offerMembers(emit, imported, inferred, sourceText = text, mini = mini)
@@ -316,7 +321,7 @@ class LyngCompletionContributor : CompletionContributor() {
}
// If MiniAst didn't populate members (empty), try to scan class body text for member signatures
if (localClass.members.isEmpty()) {
val scanned = scanLocalClassMembersFromText(mini, text = sourceText, cls = localClass)
val scanned = DocLookupUtils.scanLocalClassMembersFromText(mini, text = sourceText, cls = localClass)
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Local scan for class ${localClass.name}: found ${scanned.size} members -> ${scanned.keys}")
for ((name, sig) in scanned) {
when (sig.kind) {
@@ -421,7 +426,7 @@ class LyngCompletionContributor : CompletionContributor() {
.firstOrNull { it.type != null } ?: rep
val builder = LookupElementBuilder.create(name)
.withIcon(icon)
.withTypeText(typeOf((chosen as MiniMemberValDecl).type), true)
.withTypeText(typeOf(chosen.type), true)
emit(builder)
}
is MiniInitDecl -> {}
@@ -542,441 +547,6 @@ class LyngCompletionContributor : CompletionContributor() {
// --- MiniAst-based inference helpers ---
private fun previousIdentifierBeforeDot(text: String, dotPos: Int): String? {
var i = dotPos - 1
// skip whitespace
while (i >= 0 && text[i].isWhitespace()) i--
val end = i + 1
while (i >= 0 && TextCtx.isIdentChar(text[i])) i--
val start = i + 1
return if (start < end) text.substring(start, end) else null
}
private fun guessReceiverClassViaMini(mini: MiniScript?, text: String, dotPos: Int, imported: List<String>): String? {
if (mini == null) return null
val i = TextCtx.prevNonWs(text, dotPos - 1)
if (i < 0) return null
val wordRange = TextCtx.wordRangeAt(text, i + 1) ?: return null
val ident = text.substring(wordRange.startOffset, wordRange.endOffset)
// 1) Global declarations in current file (val/var/fun/class/enum)
val d = mini.declarations.firstOrNull { it.name == ident }
if (d != null) {
return when (d) {
is MiniClassDecl -> d.name
is MiniEnumDecl -> d.name
is MiniValDecl -> simpleClassNameOf(d.type)
is MiniFunDecl -> simpleClassNameOf(d.returnType)
}
}
// 2) Parameters in any function (best-effort without scope mapping)
val paramType = mini.declarations.filterIsInstance<MiniFunDecl>()
.asSequence()
.flatMap { it.params.asSequence() }
.firstOrNull { it.name == ident }?.type
simpleClassNameOf(paramType)?.let { return it }
// 3) Recursive chaining: Base.ident.
val dotBefore = TextCtx.findDotLeft(text, wordRange.startOffset)
if (dotBefore != null) {
val receiverClass = guessReceiverClassViaMini(mini, text, dotBefore, imported)
?: guessReceiverClass(text, dotBefore, imported)
if (receiverClass != null) {
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, ident, mini)
if (resolved != null) {
val rt = when (val m = resolved.second) {
is MiniMemberFunDecl -> m.returnType
is MiniMemberValDecl -> m.type
else -> null
}
return simpleClassNameOf(rt)
}
}
}
// 4) Check if it's a known class (static access)
val classes = DocLookupUtils.aggregateClasses(imported, mini)
if (classes.containsKey(ident)) return ident
return null
}
private fun guessReturnClassFromMemberCallBeforeMini(mini: MiniScript?, text: String, dotPos: Int, imported: List<String>): String? {
if (mini == null) return null
var i = TextCtx.prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
// back to matching '('
i--
var depth = 0
while (i >= 0) {
when (text[i]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
i--
}
if (i < 0 || text[i] != '(') return null
var j = i - 1
while (j >= 0 && text[j].isWhitespace()) j--
val end = j + 1
while (j >= 0 && TextCtx.isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
// Ensure member call: dot before callee
var k = start - 1
while (k >= 0 && text[k].isWhitespace()) k--
if (k < 0 || text[k] != '.') return null
val prevDot = k
// Resolve receiver class via MiniAst (ident like `x`)
val receiverClass = guessReceiverClassViaMini(mini, text, prevDot, imported) ?: return null
// If receiver class is a locally declared class, resolve member on it
val localClass = mini.declarations.filterIsInstance<MiniClassDecl>().firstOrNull { it.name == receiverClass }
if (localClass != null) {
val mm = localClass.members.firstOrNull { it.name == callee }
if (mm != null) {
val rt = when (mm) {
is MiniMemberFunDecl -> mm.returnType
is MiniMemberValDecl -> mm.type
else -> null
}
return simpleClassNameOf(rt)
} else {
// Try to scan class body text for method signature and extract return type
val sigs = scanLocalClassMembersFromText(mini, text, localClass)
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Local scan for return type in ${receiverClass}.${callee}: candidates=${sigs.keys}")
val sig = sigs[callee]
if (sig != null && sig.typeText != null) return sig.typeText
}
}
// Else fallback to registry-based resolution (covers imported classes)
return DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, callee, mini)?.second?.let { m ->
val rt = when (m) {
is MiniMemberFunDecl -> m.returnType
is MiniMemberValDecl -> m.type
is MiniInitDecl -> null
}
simpleClassNameOf(rt)
}
}
private data class ScannedSig(val kind: String, val params: List<String>?, val typeText: String?)
private fun scanLocalClassMembersFromText(mini: MiniScript, text: String, cls: MiniClassDecl): Map<String, ScannedSig> {
val src = mini.range.start.source
val start = src.offsetOf(cls.bodyRange?.start ?: cls.range.start)
val end = src.offsetOf(cls.bodyRange?.end ?: cls.range.end).coerceAtMost(text.length)
if (start !in 0..end) return emptyMap()
val body = text.substring(start, end)
val map = LinkedHashMap<String, ScannedSig>()
// fun name(params): Type
val funRe = Regex("^\\s*fun\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\(([^)]*)\\)\\s*(?::\\s*([A-Za-z_][A-Za-z0-9_]*))?", RegexOption.MULTILINE)
for (m in funRe.findAll(body)) {
val name = m.groupValues.getOrNull(1) ?: continue
val params = m.groupValues.getOrNull(2)?.split(',')?.mapNotNull { it.trim().takeIf { it.isNotEmpty() } } ?: emptyList()
val type = m.groupValues.getOrNull(3)?.takeIf { it.isNotBlank() }
map[name] = ScannedSig("fun", params, type)
}
// val/var name: Type
val valRe = Regex("^\\s*(val|var)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(?::\\s*([A-Za-z_][A-Za-z0-9_]*))?", RegexOption.MULTILINE)
for (m in valRe.findAll(body)) {
val kind = m.groupValues.getOrNull(1) ?: continue
val name = m.groupValues.getOrNull(2) ?: continue
val type = m.groupValues.getOrNull(3)?.takeIf { it.isNotBlank() }
map.putIfAbsent(name, ScannedSig(kind, null, type))
}
return map
}
private fun guessReceiverClass(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
// 1) Try call-based: ClassName(...).
DocLookupUtils.guessClassFromCallBefore(text, dotPos, imported, mini)?.let { return it }
// 2) Literal heuristics based on the immediate char before '.'
var i = TextCtx.prevNonWs(text, dotPos - 1)
if (i >= 0) {
when (text[i]) {
'"' -> {
// Either regular or triple-quoted string; both map to String
return "String"
}
']' -> return "List" // very rough heuristic
'}' -> return "Dict" // map/dictionary literal heuristic
')' -> {
// Parenthesized expression: walk back to matching '(' and inspect inner expression
var j = i - 1
var depth = 0
while (j >= 0) {
when (text[j]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
j--
}
if (j >= 0 && text[j] == '(') {
val innerS = (j + 1).coerceAtLeast(0)
val innerE = i.coerceAtMost(text.length)
if (innerS < innerE) {
val inner = text.substring(innerS, innerE).trim()
if (inner.startsWith('"') && inner.endsWith('"')) return "String"
if (inner.startsWith('[') && inner.endsWith(']')) return "List"
if (inner.startsWith('{') && inner.endsWith('}')) return "Dict"
}
}
}
}
// If it's an identifier, check if it's a known class (static access) or chain
val wordRange = TextCtx.wordRangeAt(text, i + 1)
if (wordRange != null) {
val ident = text.substring(wordRange.startOffset, wordRange.endOffset)
val classes = DocLookupUtils.aggregateClasses(imported, mini)
if (classes.containsKey(ident)) return ident
// Chaining without MiniAst
val dotBefore = TextCtx.findDotLeft(text, wordRange.startOffset)
if (dotBefore != null) {
val base = guessReceiverClass(text, dotBefore, imported, mini)
if (base != null) {
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, base, ident, mini)
if (resolved != null) {
val rt = when (val m = resolved.second) {
is MiniMemberFunDecl -> m.returnType
is MiniMemberValDecl -> m.type
else -> null
}
return simpleClassNameOf(rt)
}
}
}
}
// Numeric literal: support decimal, hex (0x..), and scientific notation (1e-3)
var j = i
var hasDigits = false
var hasDot = false
var hasExp = false
// Walk over digits, letters for hex, dots, and exponent markers
while (j >= 0) {
val ch = text[j]
if (ch.isDigit()) { hasDigits = true; j-- ; continue }
if (ch == '.') { hasDot = true; j-- ; continue }
if (ch == 'e' || ch == 'E') { hasExp = true; j-- ; // optional sign directly before digits
if (j >= 0 && (text[j] == '+' || text[j] == '-')) j--
continue
}
if (ch in listOf('x','X')) { // part of 0x prefix
j--
continue
}
if (ch == 'a' || ch == 'b' || ch == 'c' || ch == 'd' || ch == 'f' ||
ch == 'A' || ch == 'B' || ch == 'C' || ch == 'D' || ch == 'F') {
// hex digit in 0x...
j--
continue
}
break
}
// Now check for 0x/0X prefix
val k = j
val isHex = k >= 1 && text[k] == '0' && (text[k+1] == 'x' || text[k+1] == 'X')
if (hasDigits) {
return if (isHex) "Int" else if (hasDot || hasExp) "Real" else "Int"
}
// 3) this@Type or as Type
val identRange = TextCtx.wordRangeAt(text, i + 1)
if (identRange != null) {
val ident = text.substring(identRange.startOffset, identRange.endOffset)
// if it's "as Type", we want Type
var k2 = TextCtx.prevNonWs(text, identRange.startOffset - 1)
if (k2 >= 1 && text[k2] == 's' && text[k2 - 1] == 'a' && (k2 - 1 == 0 || !text[k2 - 2].isLetterOrDigit())) {
return ident
}
// if it's "this@Type", we want Type
if (k2 >= 0 && text[k2] == '@') {
val k3 = TextCtx.prevNonWs(text, k2 - 1)
if (k3 >= 3 && text.substring(k3 - 3, k3 + 1) == "this") {
return ident
}
}
}
}
return null
}
/**
* Try to infer the class of the return value of the member call immediately before the dot.
* Example: `Path(".." ).lines().<caret>` → detects `lines()` on receiver class `Path` and returns `Iterator`.
*/
private fun guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = TextCtx.prevNonWs(text, dotPos - 1)
if (i < 0) return null
// We expect a call just before the dot, i.e., ')' ... '.'
if (text[i] != ')') return null
// Walk back to matching '('
i--
var depth = 0
while (i >= 0) {
val ch = text[i]
when (ch) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
i--
}
if (i < 0 || text[i] != '(') return null
// Identify callee identifier just before '('
var j = i - 1
while (j >= 0 && text[j].isWhitespace()) j--
val end = j + 1
while (j >= 0 && TextCtx.isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
// Ensure it's a member call (there must be a dot immediately before the callee, ignoring spaces)
var k = start - 1
while (k >= 0 && text[k].isWhitespace()) k--
if (k < 0 || text[k] != '.') return null
val prevDot = k
// Infer receiver class at the previous dot
val receiverClass = guessReceiverClass(text, prevDot, imported, mini) ?: return null
// Resolve the callee as a member of receiver class, including inheritance
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, callee, mini) ?: return null
val member = resolved.second
val returnType = when (member) {
is MiniMemberFunDecl -> member.returnType
is MiniMemberValDecl -> member.type
is MiniInitDecl -> null
}
return simpleClassNameOf(returnType)
}
/**
* Infer return class of a top-level call right before the dot: e.g., `files().<caret>`.
* We extract callee name and resolve it among imported modules' top-level functions.
*/
private fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = TextCtx.prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
// Walk back to matching '('
i--
var depth = 0
while (i >= 0) {
val ch = text[i]
when (ch) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
i--
}
if (i < 0 || text[i] != '(') return null
// Extract callee ident before '('
var j = i - 1
while (j >= 0 && text[j].isWhitespace()) j--
val end = j + 1
while (j >= 0 && TextCtx.isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
// If it's a member call, bail out (handled in member-call inference)
var k = start - 1
while (k >= 0 && text[k].isWhitespace()) k--
if (k >= 0 && text[k] == '.') return null
// Resolve top-level function in imported modules
for (mod in imported) {
val decls = BuiltinDocRegistry.docsForModule(mod)
val fn = decls.asSequence().filterIsInstance<MiniFunDecl>().firstOrNull { it.name == callee }
if (fn != null) return simpleClassNameOf(fn.returnType)
}
// Also check local declarations
mini?.declarations?.filterIsInstance<MiniFunDecl>()?.firstOrNull { it.name == callee }?.let { return simpleClassNameOf(it.returnType) }
return null
}
/**
* Fallback: if we can at least extract a callee name before the dot and it exists across common classes,
* derive its return type using cross-class lookup (Iterable/Iterator/List preference). This ignores the receiver.
* Example: `something.lines().<caret>` where `something` type is unknown, but `lines()` commonly returns Iterator<String>.
*/
private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = TextCtx.prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
// Walk back to matching '('
i--
var depth = 0
while (i >= 0) {
val ch = text[i]
when (ch) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
i--
}
if (i < 0 || text[i] != '(') return null
// Extract callee ident before '('
var j = i - 1
while (j >= 0 && text[j].isWhitespace()) j--
val end = j + 1
while (j >= 0 && TextCtx.isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
// Try cross-class resolution
val resolved = DocLookupUtils.findMemberAcrossClasses(imported, callee, mini) ?: return null
val member = resolved.second
val returnType = when (member) {
is MiniMemberFunDecl -> member.returnType
is MiniMemberValDecl -> member.type
is MiniInitDecl -> null
}
return simpleClassNameOf(returnType)
}
/** Convert a MiniTypeRef to a simple class name as used by docs (e.g., Iterator from Iterator<String>). */
private fun simpleClassNameOf(t: MiniTypeRef?): String? = when (t) {
null -> null
is MiniTypeName -> t.segments.lastOrNull()?.name
is MiniGenericType -> simpleClassNameOf(t.base)
is MiniFunctionType -> null
is MiniTypeVar -> null
}
private fun buildMiniAst(text: String): MiniScript? {
return try {
val sink = MiniAstBuilder()
val provider = IdeLenientImportProvider.create()
val src = Source("<ide>", text)
runBlocking { Compiler.compileWithMini(src, provider, sink) }
sink.build()
} catch (_: Throwable) {
null
}
}
// Cached per PsiFile by document modification stamp
private val MINI_KEY = Key.create<MiniScript>("lyng.mini.cache")
private val STAMP_KEY = Key.create<Long>("lyng.mini.cache.stamp")
private fun buildMiniAstCached(file: PsiFile, text: String): MiniScript? {
val doc = file.viewProvider.document ?: return null
val stamp = doc.modificationStamp
val prevStamp = file.getUserData(STAMP_KEY)
val cached = file.getUserData(MINI_KEY)
if (cached != null && prevStamp != null && prevStamp == stamp) return cached
val built = buildMiniAst(text)
// Cache even null? avoid caching failures; only cache non-null
if (built != null) {
file.putUserData(MINI_KEY, built)
file.putUserData(STAMP_KEY, stamp)
}
return built
}
private fun offerParamsInScope(emit: (com.intellij.codeInsight.lookup.LookupElement) -> Unit, mini: MiniScript, text: String, caret: Int) {
val src = mini.range.start.source
// Find function whose body contains caret or whose whole range contains caret
@@ -69,105 +69,156 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
val ident = text.substring(idRange.startOffset, idRange.endOffset)
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: ident='$ident' at ${idRange.startOffset}..${idRange.endOffset} in ${file.name}")
// Build MiniAst for this file (fast and resilient). Best-effort; on failure continue with registry lookup only.
// Build MiniAst for this file (fast and resilient). Best-effort; on failure continue with partial AST.
val sink = MiniAstBuilder()
// Use lenient import provider so unresolved imports (e.g., lyng.io.fs) don't break docs
val provider = IdeLenientImportProvider.create()
val src = Source("<ide>", text)
var mini: MiniScript? = try {
val mini = try {
runBlocking { Compiler.compileWithMini(src, provider, sink) }
sink.build()
} catch (t: Throwable) {
// Do not bail out completely: we still can resolve built-in and imported docs (e.g., println)
if (DEBUG_LOG) log.warn("[LYNG_DEBUG] QuickDoc: compileWithMini failed: ${t.message}")
null
}
val haveMini = mini != null
if (mini == null) {
// Ensure we have a dummy script object to avoid NPE in downstream helpers that expect a MiniScript
mini = MiniScript(MiniRange(Pos(src, 1, 1), Pos(src, 1, 1)))
}
if (DEBUG_LOG) log.warn("[LYNG_DEBUG] QuickDoc: compileWithMini produced partial AST: ${t.message}")
sink.build()
} ?: MiniScript(MiniRange(Pos(src, 1, 1), Pos(src, 1, 1)))
val source = src
// Try resolve to: function param at position, function/class/val declaration at position
// 1) Check declarations whose name range contains offset
if (haveMini) for (d in mini.declarations) {
val s = source.offsetOf(d.nameStart)
val e = (s + d.name.length).coerceAtMost(text.length)
if (offset in s until e) {
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: matched decl '${d.name}' kind=${d::class.simpleName}")
return renderDeclDoc(d)
// 1) Use unified declaration detection
DocLookupUtils.findDeclarationAt(mini, offset, ident)?.let { (name, kind) ->
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: matched declaration '$name' kind=$kind")
// Find the actual declaration object to render
for (d in mini.declarations) {
if (d.name == name && source.offsetOf(d.nameStart) <= offset && source.offsetOf(d.nameStart) + d.name.length > offset) {
return renderDeclDoc(d)
}
// Handle members if it was a member
if (d is MiniClassDecl) {
for (m in d.members) {
if (m.name == name && source.offsetOf(m.nameStart) <= offset && source.offsetOf(m.nameStart) + m.name.length > offset) {
return when (m) {
is MiniMemberFunDecl -> renderMemberFunDoc(d.name, m)
is MiniMemberValDecl -> renderMemberValDoc(d.name, m)
is MiniInitDecl -> null
}
}
}
for (cf in d.ctorFields) {
if (cf.name == name && source.offsetOf(cf.nameStart) <= offset && source.offsetOf(cf.nameStart) + cf.name.length > offset) {
// Render as a member val
val mv = MiniMemberValDecl(
range = MiniRange(cf.nameStart, cf.nameStart), // dummy
name = cf.name,
mutable = cf.mutable,
type = cf.type,
doc = null,
nameStart = cf.nameStart
)
return renderMemberValDoc(d.name, mv)
}
}
for (cf in d.classFields) {
if (cf.name == name && source.offsetOf(cf.nameStart) <= offset && source.offsetOf(cf.nameStart) + cf.name.length > offset) {
// Render as a member val
val mv = MiniMemberValDecl(
range = MiniRange(cf.nameStart, cf.nameStart), // dummy
name = cf.name,
mutable = cf.mutable,
type = cf.type,
doc = null,
nameStart = cf.nameStart
)
return renderMemberValDoc(d.name, mv)
}
}
}
if (d is MiniEnumDecl) {
if (d.entries.contains(name) && offset >= source.offsetOf(d.range.start) && offset <= source.offsetOf(d.range.end)) {
// For enum constant, we don't have detailed docs in MiniAst yet, but we can render a title
return "<div class='doc-title'>enum constant ${d.name}.${name}</div>"
}
}
}
}
// 2) Check parameters of functions
if (haveMini) for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) {
for (p in fn.params) {
val s = source.offsetOf(p.nameStart)
val e = (s + p.name.length).coerceAtMost(text.length)
if (offset in s until e) {
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: matched param '${p.name}' in fun '${fn.name}'")
return renderParamDoc(fn, p)
// Check parameters
for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) {
for (p in fn.params) {
if (p.name == name && source.offsetOf(p.nameStart) <= offset && source.offsetOf(p.nameStart) + p.name.length > offset) {
return renderParamDoc(fn, p)
}
}
}
}
// 3) usages in current file via Binder (resolves local variables, parameters, and classes)
if (haveMini) {
try {
val binding = net.sergeych.lyng.binding.Binder.bind(text, mini)
val ref = binding.references.firstOrNull { offset in it.start until it.end }
if (ref != null) {
val sym = binding.symbols.firstOrNull { it.id == ref.symbolId }
if (sym != null) {
// Find local declaration that matches this symbol
val ds = mini.declarations.firstOrNull { decl ->
val s = source.offsetOf(decl.nameStart)
decl.name == sym.name && s == sym.declStart
}
if (ds != null) return renderDeclDoc(ds)
// Check parameters
for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) {
for (p in fn.params) {
val s = source.offsetOf(p.nameStart)
if (p.name == sym.name && s == sym.declStart) {
return renderParamDoc(fn, p)
// 3) usages in current file via Binder (resolves local variables, parameters, and classes)
try {
val binding = net.sergeych.lyng.binding.Binder.bind(text, mini)
val ref = binding.references.firstOrNull { offset in it.start until it.end }
if (ref != null) {
val sym = binding.symbols.firstOrNull { it.id == ref.symbolId }
if (sym != null) {
// Find local declaration that matches this symbol
val ds = mini.declarations.firstOrNull { decl ->
val s = source.offsetOf(decl.nameStart)
decl.name == sym.name && s == sym.declStart
}
if (ds != null) return renderDeclDoc(ds)
// Check parameters
for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) {
for (p in fn.params) {
val s = source.offsetOf(p.nameStart)
if (p.name == sym.name && s == sym.declStart) {
return renderParamDoc(fn, p)
}
}
}
// Check class members (fields/functions)
for (cls in mini.declarations.filterIsInstance<MiniClassDecl>()) {
for (m in cls.members) {
val s = source.offsetOf(m.nameStart)
if (m.name == sym.name && s == sym.declStart) {
return when (m) {
is MiniMemberFunDecl -> renderMemberFunDoc(cls.name, m)
is MiniMemberValDecl -> renderMemberValDoc(cls.name, m)
is MiniInitDecl -> null
}
}
}
// Check class members (fields/functions)
for (cls in mini.declarations.filterIsInstance<MiniClassDecl>()) {
for (m in cls.members) {
val s = source.offsetOf(m.nameStart)
if (m.name == sym.name && s == sym.declStart) {
return when (m) {
is MiniMemberFunDecl -> renderMemberFunDoc(cls.name, m)
is MiniMemberValDecl -> renderMemberValDoc(cls.name, m)
is MiniInitDecl -> null
}
}
for (cf in cls.ctorFields) {
val s = source.offsetOf(cf.nameStart)
if (cf.name == sym.name && s == sym.declStart) {
// Render as a member val
val mv = MiniMemberValDecl(
range = MiniRange(cf.nameStart, cf.nameStart), // dummy
name = cf.name,
mutable = cf.mutable,
type = cf.type,
doc = null,
nameStart = cf.nameStart
)
return renderMemberValDoc(cls.name, mv)
}
for (cf in cls.ctorFields) {
val s = source.offsetOf(cf.nameStart)
if (cf.name == sym.name && s == sym.declStart) {
// Render as a member val
val mv = MiniMemberValDecl(
range = MiniRange(cf.nameStart, cf.nameStart), // dummy
name = cf.name,
mutable = cf.mutable,
type = cf.type,
doc = null,
nameStart = cf.nameStart
)
return renderMemberValDoc(cls.name, mv)
}
}
for (cf in cls.classFields) {
val s = source.offsetOf(cf.nameStart)
if (cf.name == sym.name && s == sym.declStart) {
// Render as a member val
val mv = MiniMemberValDecl(
range = MiniRange(cf.nameStart, cf.nameStart), // dummy
name = cf.name,
mutable = cf.mutable,
type = cf.type,
doc = null,
nameStart = cf.nameStart
)
return renderMemberValDoc(cls.name, mv)
}
}
}
}
} catch (e: Throwable) {
if (DEBUG_LOG) log.warn("[LYNG_DEBUG] QuickDoc: local binder resolution failed: ${e.message}")
}
} catch (e: Throwable) {
if (DEBUG_LOG) log.warn("[LYNG_DEBUG] QuickDoc: local binder resolution failed: ${e.message}")
}
// 4) Member-context resolution first (dot immediately before identifier): handle literals and calls
run {
@@ -175,12 +226,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
?: TextCtx.findDotLeft(text, offset)
if (dotPos != null) {
// Build imported modules (MiniAst-derived if available, else lenient from text) and ensure stdlib is present
var importedModules = if (haveMini) DocLookupUtils.canonicalImportedModules(mini) else emptyList()
if (importedModules.isEmpty()) {
val fromText = extractImportsFromText(text)
importedModules = if (fromText.isEmpty()) listOf("lyng.stdlib") else fromText
}
if (!importedModules.contains("lyng.stdlib")) importedModules = importedModules + "lyng.stdlib"
val importedModules = DocLookupUtils.canonicalImportedModules(mini, text)
// Try literal and call-based receiver inference around the dot
val i = TextCtx.prevNonWs(text, dotPos - 1)
@@ -251,21 +297,14 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
}
// 4) As a fallback, if the caret is on an identifier text that matches any declaration name, show that
if (haveMini) mini.declarations.firstOrNull { it.name == ident }?.let {
mini.declarations.firstOrNull { it.name == ident }?.let {
log.info("[LYNG_DEBUG] QuickDoc: fallback by name '${it.name}' kind=${it::class.simpleName}")
return renderDeclDoc(it)
}
// 4) Consult BuiltinDocRegistry for imported modules (top-level and class members)
// Canonicalize import names using ImportManager, as users may write shortened names (e.g., "io.fs")
var importedModules = if (haveMini) DocLookupUtils.canonicalImportedModules(mini) else emptyList()
// If MiniAst failed or captured no imports, try a lightweight textual import scan
if (importedModules.isEmpty()) {
val fromText = extractImportsFromText(text)
if (fromText.isNotEmpty()) {
importedModules = fromText
}
}
var importedModules = DocLookupUtils.canonicalImportedModules(mini, text)
// Always include stdlib as a fallback context
if (!importedModules.contains("lyng.stdlib")) importedModules = importedModules + "lyng.stdlib"
// 4a) try top-level decls
@@ -373,25 +412,6 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
return null
}
/**
* Very lenient import extractor for cases when MiniAst is unavailable.
* Looks for lines like `import xxx.yyy` and returns canonical module names
* (prefixing with `lyng.` if missing).
*/
private fun extractImportsFromText(text: String): List<String> {
val result = LinkedHashSet<String>()
val re = Regex("^\\s*import\\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\\.[a-zA-Z_][a-zA-Z0-9_]*)*)", RegexOption.MULTILINE)
re.findAll(text).forEach { m ->
val raw = m.groupValues.getOrNull(1)?.trim().orEmpty()
if (raw.isNotEmpty()) {
val canon = if (raw.startsWith("lyng.")) raw else "lyng.$raw"
result.add(canon)
}
}
return result.toList()
}
// External docs registrars discovery via reflection to avoid hard dependencies on optional modules
private val externalDocsLoaded: Boolean by lazy { tryLoadExternalDocs() }
private fun ensureExternalDocsRegistered() { @Suppress("UNUSED_EXPRESSION") externalDocsLoaded }
@@ -441,7 +461,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw)
val sb = StringBuilder()
sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>")
if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc!!))
if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc))
return sb.toString()
}
@@ -462,7 +482,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw)
val sb = StringBuilder()
sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>")
if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc!!))
if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc))
return sb.toString()
}
@@ -475,7 +495,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw)
val sb = StringBuilder()
sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>")
if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc!!))
if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc))
return sb.toString()
}
@@ -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.
@@ -35,6 +35,8 @@ import com.intellij.psi.codeStyle.CodeStyleManager
import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter
import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.util.FormattingUtils.computeDesiredIndent
import net.sergeych.lyng.idea.util.FormattingUtils.findFirstNonWs
class LyngEnterHandler : EnterHandlerDelegate {
private val log = Logger.getInstance(LyngEnterHandler::class.java)
@@ -80,10 +82,22 @@ class LyngEnterHandler : EnterHandlerDelegate {
val trimmed = prevText.trimStart()
// consider only code part before // comment
val code = trimmed.substringBefore("//").trim()
if (code == "}") {
// Previously we reindented the enclosed block on Enter after a lone '}'.
// Per new behavior, this action is now bound to typing '}' instead.
// Keep Enter flow limited to indenting the new line only.
if (code == "}" || code == "*/") {
// Adjust indent for the previous line if it's a block or comment closer
val prevStart = doc.getLineStartOffset(prevLine)
CodeStyleManager.getInstance(project).adjustLineIndent(file, prevStart)
// Fallback for previous line: manual application
val desiredPrev = computeDesiredIndent(project, doc, prevLine)
val lineStartPrev = doc.getLineStartOffset(prevLine)
val lineEndPrev = doc.getLineEndOffset(prevLine)
val firstNonWsPrev = findFirstNonWs(doc, lineStartPrev, lineEndPrev)
val currentIndentLenPrev = firstNonWsPrev - lineStartPrev
if (doc.getText(TextRange(lineStartPrev, lineStartPrev + currentIndentLenPrev)) != desiredPrev) {
WriteCommandAction.runWriteCommandAction(project) {
doc.replaceString(lineStartPrev, lineStartPrev + currentIndentLenPrev, desiredPrev)
}
}
}
}
// Adjust indent for the current (new) line
@@ -159,35 +173,6 @@ class LyngEnterHandler : EnterHandlerDelegate {
caret.moveToOffset(target)
}
private fun computeDesiredIndent(project: Project, doc: Document, line: Int): String {
val options = CodeStyle.getIndentOptions(project, doc)
val start = 0
val end = doc.getLineEndOffset(line)
val snippet = doc.getText(TextRange(start, end))
val isBlankLine = doc.getLineText(line).trim().isEmpty()
val snippetForCalc = if (isBlankLine) snippet + "x" else snippet
val cfg = LyngFormatConfig(
indentSize = options.INDENT_SIZE.coerceAtLeast(1),
useTabs = options.USE_TAB_CHARACTER,
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
)
val formatted = LyngFormatter.reindent(snippetForCalc, cfg)
val lastNl = formatted.lastIndexOf('\n')
val lastLine = if (lastNl >= 0) formatted.substring(lastNl + 1) else formatted
val wsLen = lastLine.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) lastLine.length else it }
return lastLine.substring(0, wsLen)
}
private fun findFirstNonWs(doc: Document, start: Int, end: Int): Int {
var i = start
val text = doc.charsSequence
while (i < end) {
val ch = text[i]
if (ch != ' ' && ch != '\t') break
i++
}
return i
}
private fun Document.safeLineNumber(offset: Int): Int =
getLineNumber(offset.coerceIn(0, textLength))
@@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -32,31 +32,54 @@ import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter
import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
import net.sergeych.lyng.idea.util.FormattingUtils.computeDesiredIndent
import net.sergeych.lyng.idea.util.FormattingUtils.findFirstNonWs
class LyngTypedHandler : TypedHandlerDelegate() {
private val log = Logger.getInstance(LyngTypedHandler::class.java)
override fun charTyped(c: Char, project: Project, editor: Editor, file: PsiFile): Result {
if (file.language != LyngLanguage) return Result.CONTINUE
if (c != '}') return Result.CONTINUE
if (c == '}') {
val doc = editor.document
PsiDocumentManager.getInstance(project).commitDocument(doc)
val doc = editor.document
PsiDocumentManager.getInstance(project).commitDocument(doc)
val offset = editor.caretModel.offset
val line = doc.getLineNumber((offset - 1).coerceAtLeast(0))
if (line < 0) return Result.CONTINUE
val offset = editor.caretModel.offset
val line = doc.getLineNumber((offset - 1).coerceAtLeast(0))
if (line < 0) return Result.CONTINUE
val rawLine = doc.getLineText(line)
val code = rawLine.substringBefore("//").trim()
if (code == "}") {
val settings = LyngFormatterSettings.getInstance(project)
if (settings.reindentClosedBlockOnEnter) {
reindentClosedBlockAroundBrace(project, file, doc, line)
val rawLine = doc.getLineText(line)
val code = rawLine.substringBefore("//").trim()
if (code == "}") {
val settings = LyngFormatterSettings.getInstance(project)
if (settings.reindentClosedBlockOnEnter) {
reindentClosedBlockAroundBrace(project, file, doc, line)
}
// After block reindent, adjust line indent to what platform thinks (no-op in many cases)
val lineStart = doc.getLineStartOffset(line)
CodeStyleManager.getInstance(project).adjustLineIndent(file, lineStart)
}
} else if (c == '/') {
val doc = editor.document
val offset = editor.caretModel.offset
if (offset >= 2 && doc.getText(TextRange(offset - 2, offset)) == "*/") {
PsiDocumentManager.getInstance(project).commitDocument(doc)
val line = doc.getLineNumber(offset - 1)
val lineStart = doc.getLineStartOffset(line)
CodeStyleManager.getInstance(project).adjustLineIndent(file, lineStart)
// Manual application fallback
val desired = computeDesiredIndent(project, doc, line)
val lineEnd = doc.getLineEndOffset(line)
val firstNonWs = findFirstNonWs(doc, lineStart, lineEnd)
val currentIndentLen = firstNonWs - lineStart
if (doc.getText(TextRange(lineStart, lineStart + currentIndentLen)) != desired) {
WriteCommandAction.runWriteCommandAction(project) {
doc.replaceString(lineStart, lineStart + currentIndentLen, desired)
}
}
}
// After block reindent, adjust line indent to what platform thinks (no-op in many cases)
val lineStart = doc.getLineStartOffset(line)
CodeStyleManager.getInstance(project).adjustLineIndent(file, lineStart)
}
return Result.CONTINUE
}
@@ -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.
@@ -24,9 +24,8 @@ import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.codeStyle.CommonCodeStyleSettings.IndentOptions
import com.intellij.psi.codeStyle.lineIndent.LineIndentProvider
import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter
import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.util.FormattingUtils
/**
* Lightweight indentation provider for Lyng.
@@ -45,8 +44,7 @@ class LyngLineIndentProvider : LineIndentProvider {
val options = CodeStyle.getIndentOptions(project, doc)
val line = doc.getLineNumberSafe(offset)
val indent = computeDesiredIndentFromCore(doc, line, options)
return indent
return FormattingUtils.computeDesiredIndent(project, doc, line)
}
override fun isSuitableFor(language: Language?): Boolean = language == null || language == LyngLanguage
@@ -79,25 +77,4 @@ class LyngLineIndentProvider : LineIndentProvider {
return spaces / size
}
private fun computeDesiredIndentFromCore(doc: Document, line: Int, options: IndentOptions): String {
// Build a minimal text consisting of all previous lines and the current line.
// Special case: when the current line is blank (newly created by Enter), compute the
// indent as if there was a non-whitespace character at line start (append a sentinel).
val start = 0
val end = doc.getLineEndOffset(line)
val snippet = doc.getText(TextRange(start, end))
val isBlankLine = doc.getLineText(line).trim().isEmpty()
val snippetForCalc = if (isBlankLine) snippet + "x" else snippet
val cfg = LyngFormatConfig(
indentSize = options.INDENT_SIZE.coerceAtLeast(1),
useTabs = options.USE_TAB_CHARACTER,
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
)
val formatted = LyngFormatter.reindent(snippetForCalc, cfg)
// Grab the last line's leading whitespace as the indent for the current line
val lastNl = formatted.lastIndexOf('\n')
val lastLine = if (lastNl >= 0) formatted.substring(lastNl + 1) else formatted
val wsLen = lastLine.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) lastLine.length else it }
return lastLine.substring(0, wsLen)
}
}
@@ -0,0 +1,109 @@
/*
* 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.navigation
import com.intellij.icons.AllIcons
import com.intellij.navigation.ItemPresentation
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiNameIdentifierOwner
import com.intellij.psi.impl.light.LightElement
import com.intellij.util.IncorrectOperationException
import net.sergeych.lyng.idea.LyngLanguage
import javax.swing.Icon
/**
* A light PSI element representing a Lyng declaration (function, class, enum, or variable).
* Used for navigation and to provide a stable anchor for "Find Usages".
*/
class LyngDeclarationElement(
private val nameElement: PsiElement,
private val name: String,
val kind: String = "declaration"
) : LightElement(nameElement.manager, LyngLanguage), PsiNameIdentifierOwner {
override fun getName(): String = name
override fun setName(name: String): PsiElement {
throw IncorrectOperationException("Renaming is not yet supported")
}
override fun getNameIdentifier(): PsiElement = nameElement
override fun getNavigationElement(): PsiElement = nameElement
override fun getTextRange(): TextRange = nameElement.textRange
override fun getContainingFile(): PsiFile = nameElement.containingFile
override fun isValid(): Boolean = nameElement.isValid
override fun getPresentation(): ItemPresentation {
return object : ItemPresentation {
override fun getPresentableText(): String = name
override fun getLocationString(): String {
val file = containingFile
val document = PsiDocumentManager.getInstance(file.project).getDocument(file)
val line = if (document != null) document.getLineNumber(textRange.startOffset) + 1 else "?"
val column = if (document != null) {
val lineStart = document.getLineStartOffset(document.getLineNumber(textRange.startOffset))
textRange.startOffset - lineStart + 1
} else "?"
return "${file.name}:$line:$column"
}
override fun getIcon(unused: Boolean): Icon {
return when (kind) {
"Function" -> AllIcons.Nodes.Function
"Class" -> AllIcons.Nodes.Class
"Enum" -> AllIcons.Nodes.Enum
"EnumConstant" -> AllIcons.Nodes.Enum
"Variable" -> AllIcons.Nodes.Variable
"Value" -> AllIcons.Nodes.Field
"Parameter" -> AllIcons.Nodes.Parameter
"Initializer" -> AllIcons.Nodes.Method
else -> AllIcons.Nodes.Property
}
}
}
}
override fun toString(): String = "$kind:$name"
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is LyngDeclarationElement) return false
return name == other.name && nameElement == other.nameElement
}
override fun isEquivalentTo(another: PsiElement?): Boolean {
if (this === another) return true
if (another == nameElement) return true
if (another is LyngDeclarationElement) {
return name == another.name && nameElement == another.nameElement
}
return super.isEquivalentTo(another)
}
override fun hashCode(): Int {
var result = nameElement.hashCode()
result = 31 * result + name.hashCode()
return result
}
}
@@ -0,0 +1,92 @@
/*
* 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.navigation
import LyngAstManager
import com.intellij.lang.cacheBuilder.DefaultWordsScanner
import com.intellij.lang.cacheBuilder.WordsScanner
import com.intellij.lang.findUsages.FindUsagesProvider
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiElement
import com.intellij.psi.tree.TokenSet
import net.sergeych.lyng.idea.highlight.LyngLexer
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
import net.sergeych.lyng.miniast.DocLookupUtils
class LyngFindUsagesProvider : FindUsagesProvider {
override fun getWordsScanner(): WordsScanner {
return DefaultWordsScanner(
LyngLexer(),
TokenSet.create(LyngTokenTypes.IDENTIFIER),
TokenSet.create(LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT),
TokenSet.create(LyngTokenTypes.STRING)
)
}
override fun canFindUsagesFor(psiElement: PsiElement): Boolean {
return psiElement is LyngDeclarationElement || isDeclaration(psiElement)
}
private fun isDeclaration(element: PsiElement): Boolean {
val file = element.containingFile ?: return false
val mini = LyngAstManager.getMiniAst(file) ?: return false
val offset = element.textRange.startOffset
val name = element.text ?: ""
return DocLookupUtils.findDeclarationAt(mini, offset, name) != null
}
override fun getHelpId(psiElement: PsiElement): String? = null
override fun getType(element: PsiElement): String {
if (element is LyngDeclarationElement) return element.kind
val file = element.containingFile ?: return "Lyng declaration"
val mini = LyngAstManager.getMiniAst(file) ?: return "Lyng declaration"
val info = DocLookupUtils.findDeclarationAt(mini, element.textRange.startOffset, element.text ?: "")
return info?.second ?: "Lyng declaration"
}
override fun getDescriptiveName(element: PsiElement): String {
if (element is LyngDeclarationElement) {
val file = element.containingFile
val document = PsiDocumentManager.getInstance(file.project).getDocument(file)
val line = if (document != null) document.getLineNumber(element.textRange.startOffset) + 1 else "?"
val column = if (document != null) {
val lineStart = document.getLineStartOffset(document.getLineNumber(element.textRange.startOffset))
element.textRange.startOffset - lineStart + 1
} else "?"
return "${element.name} (${file.name}:$line:$column)"
}
val file = element.containingFile ?: return element.text ?: "unknown"
val mini = LyngAstManager.getMiniAst(file) ?: return element.text ?: "unknown"
val info = DocLookupUtils.findDeclarationAt(mini, element.textRange.startOffset, element.text ?: "")
val document = PsiDocumentManager.getInstance(file.project).getDocument(file)
val line = if (document != null) document.getLineNumber(element.textRange.startOffset) + 1 else "?"
val column = if (document != null) {
val lineStart = document.getLineStartOffset(document.getLineNumber(element.textRange.startOffset))
element.textRange.startOffset - lineStart + 1
} else "?"
val name = info?.first ?: element.text ?: "unknown"
return "$name (${file.name}:$line:$column)"
}
override fun getNodeText(element: PsiElement, useFullName: Boolean): String {
return (element as? LyngDeclarationElement)?.name ?: element.text ?: "unknown"
}
}
@@ -0,0 +1,58 @@
/*
* 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.navigation
import com.intellij.codeInsight.navigation.actions.GotoDeclarationHandler
import com.intellij.openapi.editor.Editor
import com.intellij.psi.PsiElement
/**
* Ensures Ctrl+B (Go to Definition) works on Lyng identifiers by resolving through LyngPsiReference.
*/
class LyngGotoDeclarationHandler : GotoDeclarationHandler {
override fun getGotoDeclarationTargets(sourceElement: PsiElement?, offset: Int, editor: Editor?): Array<PsiElement>? {
if (sourceElement == null) return null
val allTargets = mutableListOf<PsiElement>()
// Find reference at the element or its parent (sometimes the identifier token is wrapped)
val ref = sourceElement.reference ?: sourceElement.parent?.reference
if (ref is LyngPsiReference) {
val resolved = ref.multiResolve(false)
allTargets.addAll(resolved.mapNotNull { it.element })
} else {
// Manual check if not picked up by reference (e.g. if contributor didn't run yet)
val manualRef = LyngPsiReference(sourceElement)
val manualResolved = manualRef.multiResolve(false)
allTargets.addAll(manualResolved.mapNotNull { it.element })
}
if (allTargets.isEmpty()) return null
// If there is only one target and it's equivalent to the source, return null.
// This allows IDEA to treat it as a declaration site and trigger "Show Usages".
if (allTargets.size == 1) {
val target = allTargets[0]
if (target == sourceElement || target.isEquivalentTo(sourceElement)) {
return null
}
}
return allTargets.toTypedArray()
}
}
@@ -0,0 +1,48 @@
/*
* 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.navigation
import LyngAstManager
import com.intellij.icons.AllIcons
import com.intellij.ide.IconProvider
import com.intellij.psi.PsiElement
import net.sergeych.lyng.miniast.DocLookupUtils
import javax.swing.Icon
class LyngIconProvider : IconProvider() {
override fun getIcon(element: PsiElement, flags: Int): Icon? {
val file = element.containingFile ?: return null
val mini = LyngAstManager.getMiniAst(file) ?: return null
val info = DocLookupUtils.findDeclarationAt(mini, element.textRange.startOffset, element.text ?: "")
if (info != null) {
return when (info.second) {
"Function" -> AllIcons.Nodes.Function
"Class" -> AllIcons.Nodes.Class
"Enum" -> AllIcons.Nodes.Enum
"EnumConstant" -> AllIcons.Nodes.Enum
"Variable" -> AllIcons.Nodes.Variable
"Value" -> AllIcons.Nodes.Field
"Parameter" -> AllIcons.Nodes.Parameter
"Initializer" -> AllIcons.Nodes.Method
else -> null
}
}
return null
}
}
@@ -0,0 +1,204 @@
/*
* 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.navigation
import LyngAstManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import com.intellij.psi.*
import com.intellij.psi.search.FilenameIndex
import com.intellij.psi.search.GlobalSearchScope
import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.idea.util.TextCtx
import net.sergeych.lyng.miniast.*
class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase<PsiElement>(element, TextRange(0, element.textLength)) {
override fun multiResolve(incompleteCode: Boolean): Array<ResolveResult> {
val file = element.containingFile
val text = file.text
val offset = element.textRange.startOffset
val name = element.text ?: ""
val results = mutableListOf<ResolveResult>()
val mini = LyngAstManager.getMiniAst(file) ?: return emptyArray()
val binding = LyngAstManager.getBinding(file)
// 1. Member resolution (obj.member)
val dotPos = TextCtx.findDotLeft(text, offset)
if (dotPos != null) {
val imported = DocLookupUtils.canonicalImportedModules(mini, text)
val receiverClass = DocLookupUtils.guessReceiverClassViaMini(mini, text, dotPos, imported, binding)
?: DocLookupUtils.guessReceiverClass(text, dotPos, imported, mini)
if (receiverClass != null) {
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, name, mini)
if (resolved != null) {
val owner = resolved.first
val member = resolved.second
// We need to find the actual PSI element for this member
val targetFile = findFileForClass(file.project, owner) ?: file
val targetMini = LyngAstManager.getMiniAst(targetFile)
if (targetMini != null) {
val targetSrc = targetMini.range.start.source
val off = targetSrc.offsetOf(member.nameStart)
targetFile.findElementAt(off)?.let {
val kind = when(member) {
is MiniMemberFunDecl -> "Function"
is MiniMemberValDecl -> if (member.mutable) "Variable" else "Value"
is MiniInitDecl -> "Initializer"
}
results.add(PsiElementResolveResult(LyngDeclarationElement(it, member.name, kind)))
}
}
}
}
// If we couldn't resolve exactly, we might still want to search globally but ONLY for members
if (results.isEmpty()) {
results.addAll(resolveGlobally(file.project, name, membersOnly = true))
}
} else {
// 2. Local resolution via Binder
if (binding != null) {
val ref = binding.references.firstOrNull { offset >= it.start && offset < it.end }
if (ref != null) {
val sym = binding.symbols.firstOrNull { it.id == ref.symbolId }
if (sym != null && sym.declStart >= 0) {
file.findElementAt(sym.declStart)?.let {
results.add(PsiElementResolveResult(LyngDeclarationElement(it, sym.name, sym.kind.name)))
}
}
}
}
// 3. Global project scan
// Only search globally if we haven't found a strong local match
if (results.isEmpty()) {
results.addAll(resolveGlobally(file.project, name))
}
}
// 4. Filter results to exclude duplicates
// Use a more robust de-duplication that prefers the raw element if multiple refer to the same thing
val filtered = mutableListOf<ResolveResult>()
for (res in results) {
val el = res.element ?: continue
val nav = if (el is LyngDeclarationElement) el.navigationElement else el
if (filtered.none { existing ->
val exEl = existing.element
val exNav = if (exEl is LyngDeclarationElement) exEl.navigationElement else exEl
exNav == nav || (exNav != null && exNav.isEquivalentTo(nav))
}) {
filtered.add(res)
}
}
return filtered.toTypedArray()
}
private fun findFileForClass(project: Project, className: String): PsiFile? {
val psiManager = PsiManager.getInstance(project)
// 1. Try file with matching name first (optimization)
val matchingFiles = FilenameIndex.getFilesByName(project, "$className.lyng", GlobalSearchScope.projectScope(project))
for (file in matchingFiles) {
val mini = LyngAstManager.getMiniAst(file) ?: continue
if (mini.declarations.any { (it is MiniClassDecl && it.name == className) || (it is MiniEnumDecl && it.name == className) }) {
return file
}
}
// 2. Fallback to full project scan
val allFiles = FilenameIndex.getAllFilesByExt(project, "lyng", GlobalSearchScope.projectScope(project))
for (vFile in allFiles) {
val file = psiManager.findFile(vFile) ?: continue
if (matchingFiles.contains(file)) continue // already checked
val mini = LyngAstManager.getMiniAst(file) ?: continue
if (mini.declarations.any { (it is MiniClassDecl && it.name == className) || (it is MiniEnumDecl && it.name == className) }) {
return file
}
}
return null
}
override fun resolve(): PsiElement? {
val results = multiResolve(false)
if (results.isEmpty()) return null
val target = results[0].element ?: return null
// If the target is equivalent to our source element, return the source element itself.
// This is crucial for IDEA to recognize we are already at the declaration site
// and trigger "Show Usages" instead of performing a no-op navigation.
if (target == element || target.isEquivalentTo(element)) {
return element
}
return target
}
private fun resolveGlobally(project: Project, name: String, membersOnly: Boolean = false): List<ResolveResult> {
val results = mutableListOf<ResolveResult>()
val files = FilenameIndex.getAllFilesByExt(project, "lyng", GlobalSearchScope.projectScope(project))
val psiManager = PsiManager.getInstance(project)
for (vFile in files) {
val file = psiManager.findFile(vFile) ?: continue
val mini = LyngAstManager.getMiniAst(file) ?: continue
val src = mini.range.start.source
fun addIfMatch(dName: String, nameStart: net.sergeych.lyng.Pos, dKind: String) {
if (dName == name) {
val off = src.offsetOf(nameStart)
file.findElementAt(off)?.let {
results.add(PsiElementResolveResult(LyngDeclarationElement(it, dName, dKind)))
}
}
}
for (d in mini.declarations) {
if (!membersOnly) {
val dKind = when(d) {
is net.sergeych.lyng.miniast.MiniFunDecl -> "Function"
is net.sergeych.lyng.miniast.MiniClassDecl -> "Class"
is net.sergeych.lyng.miniast.MiniEnumDecl -> "Enum"
is net.sergeych.lyng.miniast.MiniValDecl -> if (d.mutable) "Variable" else "Value"
}
addIfMatch(d.name, d.nameStart, dKind)
}
// Check members of classes and enums
val members = when(d) {
is MiniClassDecl -> d.members
is MiniEnumDecl -> DocLookupUtils.enumToSyntheticClass(d).members
else -> emptyList()
}
for (m in members) {
val mKind = when(m) {
is net.sergeych.lyng.miniast.MiniMemberFunDecl -> "Function"
is net.sergeych.lyng.miniast.MiniMemberValDecl -> if (m.mutable) "Variable" else "Value"
is net.sergeych.lyng.miniast.MiniInitDecl -> "Initializer"
}
addIfMatch(m.name, m.nameStart, mKind)
}
}
}
return results
}
override fun getVariants(): Array<Any> = emptyArray()
}
@@ -0,0 +1,54 @@
/*
* 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.navigation
import LyngAstManager
import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.*
import com.intellij.util.ProcessingContext
import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
import net.sergeych.lyng.miniast.DocLookupUtils
class LyngPsiReferenceContributor : PsiReferenceContributor() {
override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) {
registrar.registerReferenceProvider(
PlatformPatterns.psiElement().withLanguage(LyngLanguage),
object : PsiReferenceProvider() {
override fun getReferencesByElement(
element: PsiElement,
context: ProcessingContext
): Array<PsiReference> {
if (element.node.elementType == LyngTokenTypes.IDENTIFIER) {
val file = element.containingFile
val mini = LyngAstManager.getMiniAst(file)
if (mini != null) {
val offset = element.textRange.startOffset
val name = element.text ?: ""
if (DocLookupUtils.findDeclarationAt(mini, offset, name) != null) {
return PsiReference.EMPTY_ARRAY
}
}
return arrayOf(LyngPsiReference(element))
}
return PsiReference.EMPTY_ARRAY
}
}
)
}
}
@@ -0,0 +1,62 @@
/*
* 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.util
import com.intellij.application.options.CodeStyle
import com.intellij.openapi.editor.Document
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter
object FormattingUtils {
fun computeDesiredIndent(project: Project, doc: Document, line: Int): String {
val options = CodeStyle.getIndentOptions(project, doc)
val start = 0
val end = doc.getLineEndOffset(line)
val snippet = doc.getText(TextRange(start, end))
val lineText = if (line < doc.lineCount) {
val ls = doc.getLineStartOffset(line)
val le = doc.getLineEndOffset(line)
doc.getText(TextRange(ls, le))
} else ""
val isBlankLine = lineText.trim().isEmpty()
val snippetForCalc = if (isBlankLine) snippet + "x" else snippet
val cfg = LyngFormatConfig(
indentSize = options.INDENT_SIZE.coerceAtLeast(1),
useTabs = options.USE_TAB_CHARACTER,
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
)
val formatted = LyngFormatter.reindent(snippetForCalc, cfg)
val lastNl = formatted.lastIndexOf('\n')
val lastLine = if (lastNl >= 0) formatted.substring(lastNl + 1) else formatted
val wsLen = lastLine.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) lastLine.length else it }
return lastLine.substring(0, wsLen)
}
fun findFirstNonWs(doc: Document, start: Int, end: Int): Int {
var i = start
val text = doc.charsSequence
while (i < end) {
val ch = text[i]
if (ch != ' ' && ch != '\t') break
i++
}
return i
}
}
@@ -0,0 +1,101 @@
/*
* 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.
*
*/
a/*
* 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.util
import com.intellij.openapi.util.Key
import com.intellij.psi.PsiFile
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Source
import net.sergeych.lyng.binding.Binder
import net.sergeych.lyng.binding.BindingSnapshot
import net.sergeych.lyng.miniast.MiniAstBuilder
import net.sergeych.lyng.miniast.MiniScript
object LyngAstManager {
private val MINI_KEY = Key.create<MiniScript>("lyng.mini.cache")
private val BINDING_KEY = Key.create<BindingSnapshot>("lyng.binding.cache")
private val STAMP_KEY = Key.create<Long>("lyng.mini.cache.stamp")
fun getMiniAst(file: PsiFile): MiniScript? {
val doc = file.viewProvider.document ?: return null
val stamp = doc.modificationStamp
val prevStamp = file.getUserData(STAMP_KEY)
val cached = file.getUserData(MINI_KEY)
if (cached != null && prevStamp != null && prevStamp == stamp) return cached
val text = doc.text
val sink = MiniAstBuilder()
val built = try {
val provider = IdeLenientImportProvider.create()
val src = Source(file.name, text)
runBlocking { Compiler.compileWithMini(src, provider, sink) }
sink.build()
} catch (_: Throwable) {
sink.build()
}
if (built != null) {
file.putUserData(MINI_KEY, built)
file.putUserData(STAMP_KEY, stamp)
// Invalidate binding too
file.putUserData(BINDING_KEY, null)
}
return built
}
fun getBinding(file: PsiFile): BindingSnapshot? {
val doc = file.viewProvider.document ?: return null
val stamp = doc.modificationStamp
val prevStamp = file.getUserData(STAMP_KEY)
val cached = file.getUserData(BINDING_KEY)
if (cached != null && prevStamp != null && prevStamp == stamp) return cached
val mini = getMiniAst(file) ?: return null
val text = doc.text
val binding = try {
Binder.bind(text, mini)
} catch (_: Throwable) {
null
}
if (binding != null) {
file.putUserData(BINDING_KEY, binding)
// stamp is already set by getMiniAst
}
return binding
}
}
@@ -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.
@@ -96,6 +96,12 @@
<!-- If targeting SDKs with stable RawText API, the EP below can be enabled instead: -->
<!-- <copyPastePreProcessor implementation="net.sergeych.lyng.idea.editor.LyngCopyPastePreProcessor"/> -->
<!-- Navigation and Find Usages -->
<psi.referenceContributor language="Lyng" implementation="net.sergeych.lyng.idea.navigation.LyngPsiReferenceContributor"/>
<gotoDeclarationHandler implementation="net.sergeych.lyng.idea.navigation.LyngGotoDeclarationHandler"/>
<lang.findUsagesProvider language="Lyng" implementationClass="net.sergeych.lyng.idea.navigation.LyngFindUsagesProvider"/>
<iconProvider implementation="net.sergeych.lyng.idea.navigation.LyngIconProvider"/>
</extensions>
<actions/>