attempt to add navigation to plugin (partial success)

This commit is contained in:
Sergey Chernov 2026-01-03 11:59:50 +01:00
parent 59aefc5bc2
commit bc6613ec01
23 changed files with 1498 additions and 966 deletions

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"); * 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,7 @@ plugins {
} }
group = "net.sergeych.lyng" group = "net.sergeych.lyng"
version = "0.0.4-SNAPSHOT" version = "0.0.5-SNAPSHOT"
kotlin { kotlin {
jvmToolchain(17) jvmToolchain(17)

View File

@ -185,9 +185,9 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
fun keyForKind(k: SymbolKind) = when (k) { fun keyForKind(k: SymbolKind) = when (k) {
SymbolKind.Function -> LyngHighlighterColors.FUNCTION SymbolKind.Function -> LyngHighlighterColors.FUNCTION
SymbolKind.Class, SymbolKind.Enum -> LyngHighlighterColors.TYPE SymbolKind.Class, SymbolKind.Enum -> LyngHighlighterColors.TYPE
SymbolKind.Param -> LyngHighlighterColors.PARAMETER SymbolKind.Parameter -> LyngHighlighterColors.PARAMETER
SymbolKind.Val -> LyngHighlighterColors.VALUE SymbolKind.Value -> LyngHighlighterColors.VALUE
SymbolKind.Var -> LyngHighlighterColors.VARIABLE SymbolKind.Variable -> LyngHighlighterColors.VARIABLE
} }
// Track covered ranges to not override later heuristics // Track covered ranges to not override later heuristics

View File

@ -21,23 +21,21 @@
*/ */
package net.sergeych.lyng.idea.completion package net.sergeych.lyng.idea.completion
import LyngAstManager
import com.intellij.codeInsight.completion.* import com.intellij.codeInsight.completion.*
import com.intellij.codeInsight.lookup.LookupElementBuilder import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.icons.AllIcons import com.intellij.icons.AllIcons
import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Document import com.intellij.openapi.editor.Document
import com.intellij.openapi.util.Key
import com.intellij.patterns.PlatformPatterns import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.PsiFile import com.intellij.psi.PsiFile
import com.intellij.util.ProcessingContext import com.intellij.util.ProcessingContext
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Source
import net.sergeych.lyng.highlight.offsetOf import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.idea.LyngLanguage 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.settings.LyngFormatterSettings
import net.sergeych.lyng.idea.util.DocsBootstrap 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.idea.util.TextCtx
import net.sergeych.lyng.miniast.* import net.sergeych.lyng.miniast.*
@ -65,6 +63,12 @@ class LyngCompletionContributor : CompletionContributor() {
StdlibDocsBootstrap.ensure() StdlibDocsBootstrap.ensure()
val file: PsiFile = parameters.originalFile val file: PsiFile = parameters.originalFile
if (file.language != LyngLanguage) return 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 // Feature toggle: allow turning completion off from settings
val settings = LyngFormatterSettings.getInstance(file.project) val settings = LyngFormatterSettings.getInstance(file.project)
if (!settings.enableLyngCompletionExperimental) return 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 // 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 // Delegate computation to the shared engine to keep behavior in sync with tests
val engineItems = try { val engineItems = try {
@ -121,13 +126,13 @@ class LyngCompletionContributor : CompletionContributor() {
// Try inferring return/receiver class around the dot // Try inferring return/receiver class around the dot
val inferred = val inferred =
// Prefer MiniAst-based inference (return type from member call or receiver type) // Prefer MiniAst-based inference (return type from member call or receiver type)
guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported) DocLookupUtils.guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported, binding)
?: guessReceiverClassViaMini(mini, text, memberDotPos, imported) ?: DocLookupUtils.guessReceiverClassViaMini(mini, text, memberDotPos, imported, binding)
?: ?:
guessReturnClassFromMemberCallBefore(text, memberDotPos, imported, mini) DocLookupUtils.guessReturnClassFromMemberCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported, mini) ?: DocLookupUtils.guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported, mini) ?: DocLookupUtils.guessReturnClassAcrossKnownCallees(text, memberDotPos, imported, mini)
?: guessReceiverClass(text, memberDotPos, imported, mini) ?: DocLookupUtils.guessReceiverClass(text, memberDotPos, imported, mini)
if (inferred != null) { if (inferred != null) {
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Fallback inferred receiver/return class='$inferred' — offering its members") 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") add("lyng.stdlib")
}.toList() }.toList()
val inferredClass = val inferredClass =
guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported) DocLookupUtils.guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported, binding)
?: guessReceiverClassViaMini(mini, text, memberDotPos, imported) ?: DocLookupUtils.guessReceiverClassViaMini(mini, text, memberDotPos, imported, binding)
?: guessReturnClassFromMemberCallBefore(text, memberDotPos, imported, mini) ?: DocLookupUtils.guessReturnClassFromMemberCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported, mini) ?: DocLookupUtils.guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported, mini) ?: DocLookupUtils.guessReturnClassAcrossKnownCallees(text, memberDotPos, imported, mini)
?: guessReceiverClass(text, memberDotPos, imported, mini) ?: DocLookupUtils.guessReceiverClass(text, memberDotPos, imported, mini)
if (!inferredClass.isNullOrBlank()) { if (!inferredClass.isNullOrBlank()) {
val ext = BuiltinDocRegistry.extensionMemberNamesFor(inferredClass) val ext = BuiltinDocRegistry.extensionMemberNamesFor(inferredClass)
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Post-engine extension check for $inferredClass: ${'$'}{ext}") if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Post-engine extension check for $inferredClass: ${'$'}{ext}")
@ -234,12 +239,12 @@ class LyngCompletionContributor : CompletionContributor() {
add("lyng.stdlib") add("lyng.stdlib")
}.toList() }.toList()
val inferred = val inferred =
guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported) DocLookupUtils.guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported, binding)
?: guessReceiverClassViaMini(mini, text, memberDotPos, imported) ?: DocLookupUtils.guessReceiverClassViaMini(mini, text, memberDotPos, imported, binding)
?: guessReturnClassFromMemberCallBefore(text, memberDotPos, imported) ?: DocLookupUtils.guessReturnClassFromMemberCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported) ?: DocLookupUtils.guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported) ?: DocLookupUtils.guessReturnClassAcrossKnownCallees(text, memberDotPos, imported, mini)
?: guessReceiverClass(text, memberDotPos, imported) ?: DocLookupUtils.guessReceiverClass(text, memberDotPos, imported, mini)
if (inferred != null) { if (inferred != null) {
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Enrichment inferred class='$inferred' — offering its members") if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Enrichment inferred class='$inferred' — offering its members")
offerMembers(emit, imported, inferred, sourceText = text, mini = mini) 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 MiniAst didn't populate members (empty), try to scan class body text for member signatures
if (localClass.members.isEmpty()) { 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}") if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Local scan for class ${localClass.name}: found ${scanned.size} members -> ${scanned.keys}")
for ((name, sig) in scanned) { for ((name, sig) in scanned) {
when (sig.kind) { when (sig.kind) {
@ -421,7 +426,7 @@ class LyngCompletionContributor : CompletionContributor() {
.firstOrNull { it.type != null } ?: rep .firstOrNull { it.type != null } ?: rep
val builder = LookupElementBuilder.create(name) val builder = LookupElementBuilder.create(name)
.withIcon(icon) .withIcon(icon)
.withTypeText(typeOf((chosen as MiniMemberValDecl).type), true) .withTypeText(typeOf(chosen.type), true)
emit(builder) emit(builder)
} }
is MiniInitDecl -> {} is MiniInitDecl -> {}
@ -542,441 +547,6 @@ class LyngCompletionContributor : CompletionContributor() {
// --- MiniAst-based inference helpers --- // --- 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) { private fun offerParamsInScope(emit: (com.intellij.codeInsight.lookup.LookupElement) -> Unit, mini: MiniScript, text: String, caret: Int) {
val src = mini.range.start.source val src = mini.range.start.source
// Find function whose body contains caret or whose whole range contains caret // Find function whose body contains caret or whose whole range contains caret

View File

@ -69,105 +69,156 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
val ident = text.substring(idRange.startOffset, idRange.endOffset) 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}") 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() val sink = MiniAstBuilder()
// Use lenient import provider so unresolved imports (e.g., lyng.io.fs) don't break docs
val provider = IdeLenientImportProvider.create() val provider = IdeLenientImportProvider.create()
val src = Source("<ide>", text) val src = Source("<ide>", text)
var mini: MiniScript? = try { val mini = try {
runBlocking { Compiler.compileWithMini(src, provider, sink) } runBlocking { Compiler.compileWithMini(src, provider, sink) }
sink.build() sink.build()
} catch (t: Throwable) { } 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 produced partial AST: ${t.message}")
if (DEBUG_LOG) log.warn("[LYNG_DEBUG] QuickDoc: compileWithMini failed: ${t.message}") sink.build()
null } ?: MiniScript(MiniRange(Pos(src, 1, 1), Pos(src, 1, 1)))
}
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)))
}
val source = src val source = src
// Try resolve to: function param at position, function/class/val declaration at position // Try resolve to: function param at position, function/class/val declaration at position
// 1) Check declarations whose name range contains offset // 1) Use unified declaration detection
if (haveMini) for (d in mini.declarations) { DocLookupUtils.findDeclarationAt(mini, offset, ident)?.let { (name, kind) ->
val s = source.offsetOf(d.nameStart) if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: matched declaration '$name' kind=$kind")
val e = (s + d.name.length).coerceAtMost(text.length) // Find the actual declaration object to render
if (offset in s until e) { for (d in mini.declarations) {
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: matched decl '${d.name}' kind=${d::class.simpleName}") if (d.name == name && source.offsetOf(d.nameStart) <= offset && source.offsetOf(d.nameStart) + d.name.length > offset) {
return renderDeclDoc(d) 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>"
}
}
} }
} // Check parameters
// 2) Check parameters of functions for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) {
if (haveMini) for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) { for (p in fn.params) {
for (p in fn.params) { if (p.name == name && source.offsetOf(p.nameStart) <= offset && source.offsetOf(p.nameStart) + p.name.length > offset) {
val s = source.offsetOf(p.nameStart) return renderParamDoc(fn, p)
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)
} }
} }
} }
// 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 // 3) usages in current file via Binder (resolves local variables, parameters, and classes)
for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) { try {
for (p in fn.params) { val binding = net.sergeych.lyng.binding.Binder.bind(text, mini)
val s = source.offsetOf(p.nameStart) val ref = binding.references.firstOrNull { offset in it.start until it.end }
if (p.name == sym.name && s == sym.declStart) { if (ref != null) {
return renderParamDoc(fn, p) 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
} }
} }
} }
for (cf in cls.ctorFields) {
// Check class members (fields/functions) val s = source.offsetOf(cf.nameStart)
for (cls in mini.declarations.filterIsInstance<MiniClassDecl>()) { if (cf.name == sym.name && s == sym.declStart) {
for (m in cls.members) { // Render as a member val
val s = source.offsetOf(m.nameStart) val mv = MiniMemberValDecl(
if (m.name == sym.name && s == sym.declStart) { range = MiniRange(cf.nameStart, cf.nameStart), // dummy
return when (m) { name = cf.name,
is MiniMemberFunDecl -> renderMemberFunDoc(cls.name, m) mutable = cf.mutable,
is MiniMemberValDecl -> renderMemberValDoc(cls.name, m) type = cf.type,
is MiniInitDecl -> null doc = null,
} nameStart = cf.nameStart
} )
return renderMemberValDoc(cls.name, mv)
} }
for (cf in cls.ctorFields) { }
val s = source.offsetOf(cf.nameStart) for (cf in cls.classFields) {
if (cf.name == sym.name && s == sym.declStart) { val s = source.offsetOf(cf.nameStart)
// Render as a member val if (cf.name == sym.name && s == sym.declStart) {
val mv = MiniMemberValDecl( // Render as a member val
range = MiniRange(cf.nameStart, cf.nameStart), // dummy val mv = MiniMemberValDecl(
name = cf.name, range = MiniRange(cf.nameStart, cf.nameStart), // dummy
mutable = cf.mutable, name = cf.name,
type = cf.type, mutable = cf.mutable,
doc = null, type = cf.type,
nameStart = cf.nameStart doc = null,
) nameStart = cf.nameStart
return renderMemberValDoc(cls.name, mv) )
} 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 // 4) Member-context resolution first (dot immediately before identifier): handle literals and calls
run { run {
@ -175,12 +226,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
?: TextCtx.findDotLeft(text, offset) ?: TextCtx.findDotLeft(text, offset)
if (dotPos != null) { if (dotPos != null) {
// Build imported modules (MiniAst-derived if available, else lenient from text) and ensure stdlib is present // 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() val importedModules = DocLookupUtils.canonicalImportedModules(mini, text)
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"
// Try literal and call-based receiver inference around the dot // Try literal and call-based receiver inference around the dot
val i = TextCtx.prevNonWs(text, dotPos - 1) 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 // 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}") log.info("[LYNG_DEBUG] QuickDoc: fallback by name '${it.name}' kind=${it::class.simpleName}")
return renderDeclDoc(it) return renderDeclDoc(it)
} }
// 4) Consult BuiltinDocRegistry for imported modules (top-level and class members) // 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") // Canonicalize import names using ImportManager, as users may write shortened names (e.g., "io.fs")
var importedModules = if (haveMini) DocLookupUtils.canonicalImportedModules(mini) else emptyList() var importedModules = DocLookupUtils.canonicalImportedModules(mini, text)
// 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
}
}
// Always include stdlib as a fallback context // Always include stdlib as a fallback context
if (!importedModules.contains("lyng.stdlib")) importedModules = importedModules + "lyng.stdlib" if (!importedModules.contains("lyng.stdlib")) importedModules = importedModules + "lyng.stdlib"
// 4a) try top-level decls // 4a) try top-level decls
@ -373,25 +412,6 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
return null 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 val externalDocsLoaded: Boolean by lazy { tryLoadExternalDocs() }
private fun ensureExternalDocsRegistered() { @Suppress("UNUSED_EXPRESSION") externalDocsLoaded } 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 doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw)
val sb = StringBuilder() val sb = StringBuilder()
sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>") 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() return sb.toString()
} }
@ -462,7 +482,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw) val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw)
val sb = StringBuilder() val sb = StringBuilder()
sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>") 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() return sb.toString()
} }
@ -475,7 +495,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw) val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw)
val sb = StringBuilder() val sb = StringBuilder()
sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>") 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() return sb.toString()
} }

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"); * 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.
@ -35,6 +35,8 @@ import com.intellij.psi.codeStyle.CodeStyleManager
import net.sergeych.lyng.format.LyngFormatConfig import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter import net.sergeych.lyng.format.LyngFormatter
import net.sergeych.lyng.idea.LyngLanguage 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 { class LyngEnterHandler : EnterHandlerDelegate {
private val log = Logger.getInstance(LyngEnterHandler::class.java) private val log = Logger.getInstance(LyngEnterHandler::class.java)
@ -80,10 +82,22 @@ class LyngEnterHandler : EnterHandlerDelegate {
val trimmed = prevText.trimStart() val trimmed = prevText.trimStart()
// consider only code part before // comment // consider only code part before // comment
val code = trimmed.substringBefore("//").trim() val code = trimmed.substringBefore("//").trim()
if (code == "}") { if (code == "}" || code == "*/") {
// Previously we reindented the enclosed block on Enter after a lone '}'. // Adjust indent for the previous line if it's a block or comment closer
// Per new behavior, this action is now bound to typing '}' instead. val prevStart = doc.getLineStartOffset(prevLine)
// Keep Enter flow limited to indenting the new line only. 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 // Adjust indent for the current (new) line
@ -159,35 +173,6 @@ class LyngEnterHandler : EnterHandlerDelegate {
caret.moveToOffset(target) 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 = private fun Document.safeLineNumber(offset: Int): Int =
getLineNumber(offset.coerceIn(0, textLength)) getLineNumber(offset.coerceIn(0, textLength))

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"); * 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.
@ -32,31 +32,54 @@ import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter import net.sergeych.lyng.format.LyngFormatter
import net.sergeych.lyng.idea.LyngLanguage import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.settings.LyngFormatterSettings 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() { class LyngTypedHandler : TypedHandlerDelegate() {
private val log = Logger.getInstance(LyngTypedHandler::class.java) private val log = Logger.getInstance(LyngTypedHandler::class.java)
override fun charTyped(c: Char, project: Project, editor: Editor, file: PsiFile): Result { override fun charTyped(c: Char, project: Project, editor: Editor, file: PsiFile): Result {
if (file.language != LyngLanguage) return Result.CONTINUE 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 val offset = editor.caretModel.offset
PsiDocumentManager.getInstance(project).commitDocument(doc) val line = doc.getLineNumber((offset - 1).coerceAtLeast(0))
if (line < 0) return Result.CONTINUE
val offset = editor.caretModel.offset val rawLine = doc.getLineText(line)
val line = doc.getLineNumber((offset - 1).coerceAtLeast(0)) val code = rawLine.substringBefore("//").trim()
if (line < 0) return Result.CONTINUE if (code == "}") {
val settings = LyngFormatterSettings.getInstance(project)
val rawLine = doc.getLineText(line) if (settings.reindentClosedBlockOnEnter) {
val code = rawLine.substringBefore("//").trim() reindentClosedBlockAroundBrace(project, file, doc, line)
if (code == "}") { }
val settings = LyngFormatterSettings.getInstance(project) // After block reindent, adjust line indent to what platform thinks (no-op in many cases)
if (settings.reindentClosedBlockOnEnter) { val lineStart = doc.getLineStartOffset(line)
reindentClosedBlockAroundBrace(project, file, doc, 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 return Result.CONTINUE
} }

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"); * 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.
@ -24,9 +24,8 @@ import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.codeStyle.CommonCodeStyleSettings.IndentOptions import com.intellij.psi.codeStyle.CommonCodeStyleSettings.IndentOptions
import com.intellij.psi.codeStyle.lineIndent.LineIndentProvider 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.LyngLanguage
import net.sergeych.lyng.idea.util.FormattingUtils
/** /**
* Lightweight indentation provider for Lyng. * Lightweight indentation provider for Lyng.
@ -45,8 +44,7 @@ class LyngLineIndentProvider : LineIndentProvider {
val options = CodeStyle.getIndentOptions(project, doc) val options = CodeStyle.getIndentOptions(project, doc)
val line = doc.getLineNumberSafe(offset) val line = doc.getLineNumberSafe(offset)
val indent = computeDesiredIndentFromCore(doc, line, options) return FormattingUtils.computeDesiredIndent(project, doc, line)
return indent
} }
override fun isSuitableFor(language: Language?): Boolean = language == null || language == LyngLanguage override fun isSuitableFor(language: Language?): Boolean = language == null || language == LyngLanguage
@ -79,25 +77,4 @@ class LyngLineIndentProvider : LineIndentProvider {
return spaces / size 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)
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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"); ~ 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.
@ -96,6 +96,12 @@
<!-- If targeting SDKs with stable RawText API, the EP below can be enabled instead: --> <!-- If targeting SDKs with stable RawText API, the EP below can be enabled instead: -->
<!-- <copyPastePreProcessor implementation="net.sergeych.lyng.idea.editor.LyngCopyPastePreProcessor"/> --> <!-- <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> </extensions>
<actions/> <actions/>

View File

@ -1891,7 +1891,7 @@ class Compiler(
// create class // create class
val className = nameToken.value val className = nameToken.value
// @Suppress("UNUSED_VARIABLE") val defaultAccess = if (isStruct) AccessType.Var else AccessType.Initialization // @Suppress("UNUSED_VARIABLE") val defaultAccess = if (isStruct) AccessType.Variable else AccessType.Initialization
// @Suppress("UNUSED_VARIABLE") val defaultVisibility = Visibility.Public // @Suppress("UNUSED_VARIABLE") val defaultVisibility = Visibility.Public
// create instance constructor // create instance constructor

View File

@ -27,7 +27,7 @@ import net.sergeych.lyng.highlight.SimpleLyngHighlighter
import net.sergeych.lyng.highlight.offsetOf import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.miniast.* import net.sergeych.lyng.miniast.*
enum class SymbolKind { Class, Enum, Function, Val, Var, Param } enum class SymbolKind { Class, Enum, Function, Value, Variable, Parameter }
data class Symbol( data class Symbol(
val id: Int, val id: Int,
@ -108,7 +108,7 @@ object Binder {
for (cf in d.ctorFields) { for (cf in d.ctorFields) {
val fs = source.offsetOf(cf.nameStart) val fs = source.offsetOf(cf.nameStart)
val fe = fs + cf.name.length val fe = fs + cf.name.length
val kind = if (cf.mutable) SymbolKind.Var else SymbolKind.Val val kind = if (cf.mutable) SymbolKind.Variable else SymbolKind.Value
val fieldSym = Symbol(nextId++, cf.name, kind, fs, fe, containerId = sym.id) val fieldSym = Symbol(nextId++, cf.name, kind, fs, fe, containerId = sym.id)
symbols += fieldSym symbols += fieldSym
classes.last().fields += fieldSym.id classes.last().fields += fieldSym.id
@ -135,7 +135,7 @@ object Binder {
for (p in d.params) { for (p in d.params) {
val ps = source.offsetOf(p.nameStart) val ps = source.offsetOf(p.nameStart)
val pe = ps + p.name.length val pe = ps + p.name.length
val pk = SymbolKind.Param val pk = SymbolKind.Parameter
val paramSym = Symbol(nextId++, p.name, pk, ps, pe, containerId = sym.id) val paramSym = Symbol(nextId++, p.name, pk, ps, pe, containerId = sym.id)
fnScope.locals += paramSym.id fnScope.locals += paramSym.id
symbols += paramSym symbols += paramSym
@ -144,7 +144,7 @@ object Binder {
} }
is MiniValDecl -> { is MiniValDecl -> {
val (s, e) = nameOffsets(d.nameStart, d.name) val (s, e) = nameOffsets(d.nameStart, d.name)
val kind = if (d.mutable) SymbolKind.Var else SymbolKind.Val val kind = if (d.mutable) SymbolKind.Variable else SymbolKind.Value
val ownerClass = classContaining(s) val ownerClass = classContaining(s)
if (ownerClass != null) { if (ownerClass != null) {
// class field // class field
@ -194,7 +194,7 @@ object Binder {
.maxByOrNull { it.rangeEnd - it.rangeStart } .maxByOrNull { it.rangeEnd - it.rangeStart }
if (containerFn != null) { if (containerFn != null) {
val fnSymId = containerFn.id val fnSymId = containerFn.id
val kind = if (d.mutable) SymbolKind.Var else SymbolKind.Val val kind = if (d.mutable) SymbolKind.Variable else SymbolKind.Value
val localSym = Symbol(nextId++, d.name, kind, s, e, containerId = fnSymId) val localSym = Symbol(nextId++, d.name, kind, s, e, containerId = fnSymId)
symbols += localSym symbols += localSym
containerFn.locals += localSym.id containerFn.locals += localSym.id
@ -234,7 +234,7 @@ object Binder {
val inFn = functions.asSequence() val inFn = functions.asSequence()
.filter { it.rangeEnd > it.rangeStart && nameStart >= it.rangeStart && nameStart <= it.rangeEnd } .filter { it.rangeEnd > it.rangeStart && nameStart >= it.rangeStart && nameStart <= it.rangeEnd }
.maxByOrNull { it.rangeEnd - it.rangeStart } .maxByOrNull { it.rangeEnd - it.rangeStart }
val kind = if (kw.equals("var", true)) SymbolKind.Var else SymbolKind.Val val kind = if (kw.equals("var", true)) SymbolKind.Variable else SymbolKind.Value
if (inFn != null) { if (inFn != null) {
val localSym = Symbol(nextId++, text.substring(nameStart, nameEnd), kind, nameStart, nameEnd, containerId = inFn.id) val localSym = Symbol(nextId++, text.substring(nameStart, nameEnd), kind, nameStart, nameEnd, containerId = inFn.id)
symbols += localSym symbols += localSym

View File

@ -62,42 +62,32 @@ object CompletionEngineLight {
StdlibDocsBootstrap.ensure() StdlibDocsBootstrap.ensure()
val prefix = prefixAt(text, caret) val prefix = prefixAt(text, caret)
val mini = buildMiniAst(text) val mini = buildMiniAst(text)
// Build imported modules as a UNION of MiniAst-derived and textual extraction, always including stdlib val imported: List<String> = DocLookupUtils.canonicalImportedModules(mini ?: return emptyList(), text)
run {
// no-op block to keep local scope tidy
}
val fromMini: List<String> = mini?.let { DocLookupUtils.canonicalImportedModules(it) } ?: emptyList()
val fromText: List<String> = extractImportsFromText(text)
val imported: List<String> = LinkedHashSet<String>().apply {
fromMini.forEach { add(it) }
fromText.forEach { add(it) }
add("lyng.stdlib")
}.toList()
val cap = 200 val cap = 200
val out = ArrayList<CompletionItem>(64) val out = ArrayList<CompletionItem>(64)
// Member context detection: dot immediately before caret or before current word start // Member context detection: dot immediately before caret or before current word start
val word = wordRangeAt(text, caret) val word = DocLookupUtils.wordRangeAt(text, caret)
val memberDot = findDotLeft(text, word?.first ?: caret) val memberDot = DocLookupUtils.findDotLeft(text, word?.first ?: caret)
if (memberDot != null) { if (memberDot != null) {
// 0) Try chained member call return type inference // 0) Try chained member call return type inference
guessReturnClassFromMemberCallBefore(text, memberDot, imported, mini)?.let { cls -> DocLookupUtils.guessReturnClassFromMemberCallBefore(text, memberDot, imported, mini)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls, mini) offerMembersAdd(out, prefix, imported, cls, mini)
return out return out
} }
// 0a) Top-level call before dot // 0a) Top-level call before dot
guessReturnClassFromTopLevelCallBefore(text, memberDot, imported, mini)?.let { cls -> DocLookupUtils.guessReturnClassFromTopLevelCallBefore(text, memberDot, imported, mini)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls, mini) offerMembersAdd(out, prefix, imported, cls, mini)
return out return out
} }
// 0b) Across-known-callees (Iterable/Iterator/List preference) // 0b) Across-known-callees (Iterable/Iterator/List preference)
guessReturnClassAcrossKnownCallees(text, memberDot, imported, mini)?.let { cls -> DocLookupUtils.guessReturnClassAcrossKnownCallees(text, memberDot, imported, mini)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls, mini) offerMembersAdd(out, prefix, imported, cls, mini)
return out return out
} }
// 1) Receiver inference fallback // 1) Receiver inference fallback
(guessReceiverClassViaMini(mini, text, memberDot, imported) ?: guessReceiverClass(text, memberDot, imported, mini))?.let { cls -> (DocLookupUtils.guessReceiverClassViaMini(mini, text, memberDot, imported) ?: DocLookupUtils.guessReceiverClass(text, memberDot, imported, mini))?.let { cls ->
offerMembersAdd(out, prefix, imported, cls, mini) offerMembersAdd(out, prefix, imported, cls, mini)
return out return out
} }
@ -247,228 +237,6 @@ object CompletionEngineLight {
// --- Inference helpers (text-only, PSI-free) --- // --- Inference helpers (text-only, PSI-free) ---
private fun guessReceiverClassViaMini(mini: MiniScript?, text: String, dotPos: Int, imported: List<String>): String? {
if (mini == null) return null
val i = prevNonWs(text, dotPos - 1)
if (i < 0) return null
val wordRange = wordRangeAt(text, i + 1) ?: return null
val ident = text.substring(wordRange.first, wordRange.second)
// 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) Recursive chaining: Base.ident.
val dotBefore = findDotLeft(text, wordRange.first)
if (dotBefore != null) {
val receiverClass = guessReceiverClassViaMini(mini, text, dotBefore, imported)
?: guessReceiverClass(text, dotBefore, imported, mini)
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)
}
}
}
// 3) 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 guessReceiverClass(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
DocLookupUtils.guessClassFromCallBefore(text, dotPos, imported, mini)?.let { return it }
var i = prevNonWs(text, dotPos - 1)
if (i >= 0) {
when (text[i]) {
'"' -> return "String"
']' -> return "List"
'}' -> return "Dict"
')' -> {
// Parenthesized expression: walk back to matching '(' and inspect the 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"
}
}
}
}
// Numeric literal: decimal/int/hex/scientific
var j = i
var hasDigits = false
var hasDot = false
var hasExp = false
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--; if (j >= 0 && (text[j] == '+' || text[j] == '-')) j--; continue }
if (ch in listOf('x','X','a','b','c','d','f','A','B','C','D','F')) { j--; continue }
break
}
if (hasDigits) return if (hasDot || hasExp) "Real" else "Int"
// 3) this@Type or as Type
val identRange = wordRangeAt(text, i + 1)
if (identRange != null) {
val ident = text.substring(identRange.first, identRange.second)
// if it's "as Type", we want Type
var k = prevNonWs(text, identRange.first - 1)
if (k >= 1 && text[k] == 's' && text[k - 1] == 'a' && (k - 1 == 0 || !text[k - 2].isLetterOrDigit())) {
return ident
}
// if it's "this@Type", we want Type
if (k >= 0 && text[k] == '@') {
val k2 = prevNonWs(text, k - 1)
if (k2 >= 3 && text.substring(k2 - 3, k2 + 1) == "this") {
return ident
}
}
// 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 guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
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 && isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
var k = start - 1
while (k >= 0 && text[k].isWhitespace()) k--
if (k < 0 || text[k] != '.') return null
val prevDot = k
val receiverClass = guessReceiverClass(text, prevDot, imported, mini) ?: return null
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, callee, mini) ?: return null
val member = resolved.second
val ret = when (member) {
is MiniMemberFunDecl -> member.returnType
is MiniMemberValDecl -> member.type
is MiniInitDecl -> null
}
return simpleClassNameOf(ret)
}
private fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
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 && isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
var k = start - 1
while (k >= 0 && text[k].isWhitespace()) k--
if (k >= 0 && text[k] == '.') return null // was a member call
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
}
private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
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 && isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
val resolved = DocLookupUtils.findMemberAcrossClasses(imported, callee, mini) ?: return null
val member = resolved.second
val ret = when (member) {
is MiniMemberFunDecl -> member.returnType
is MiniMemberValDecl -> member.type
is MiniInitDecl -> null
}
return simpleClassNameOf(ret)
}
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
}
// --- MiniAst and small utils ---
private suspend fun buildMiniAst(text: String): MiniScript? { private suspend fun buildMiniAst(text: String): MiniScript? {
val sink = MiniAstBuilder() val sink = MiniAstBuilder()
return try { return try {
@ -499,42 +267,8 @@ object CompletionEngineLight {
private fun prefixAt(text: String, offset: Int): String { private fun prefixAt(text: String, offset: Int): String {
val off = offset.coerceIn(0, text.length) val off = offset.coerceIn(0, text.length)
var i = (off - 1).coerceAtLeast(0) var i = (off - 1).coerceAtLeast(0)
while (i >= 0 && isIdentChar(text[i])) i-- while (i >= 0 && DocLookupUtils.isIdentChar(text[i])) i--
val start = i + 1 val start = i + 1
return if (start in 0..text.length && start <= off) text.substring(start, off) else "" return if (start in 0..text.length && start <= off) text.substring(start, off) else ""
} }
private fun wordRangeAt(text: String, offset: Int): Pair<Int, Int>? {
if (text.isEmpty()) return null
val off = offset.coerceIn(0, text.length)
var s = off
var e = off
while (s > 0 && isIdentChar(text[s - 1])) s--
while (e < text.length && isIdentChar(text[e])) e++
return if (s < e) s to e else null
}
private fun findDotLeft(text: String, offset: Int): Int? {
var i = (offset - 1).coerceAtLeast(0)
while (i >= 0 && text[i].isWhitespace()) i--
return if (i >= 0 && text[i] == '.') i else null
}
private fun prevNonWs(text: String, start: Int): Int {
var i = start.coerceAtMost(text.length - 1)
while (i >= 0 && text[i].isWhitespace()) i--
return i
}
private fun isIdentChar(c: Char): Boolean = c == '_' || c.isLetterOrDigit()
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()) result.add(if (raw.startsWith("lyng.")) raw else "lyng.$raw")
}
return result.toList()
}
} }

View File

@ -20,27 +20,156 @@
*/ */
package net.sergeych.lyng.miniast package net.sergeych.lyng.miniast
import net.sergeych.lyng.binding.BindingSnapshot
import net.sergeych.lyng.highlight.offsetOf
object DocLookupUtils { object DocLookupUtils {
fun findDeclarationAt(mini: MiniScript, offset: Int, name: String): Pair<String, String>? {
val src = mini.range.start.source
fun matches(p: net.sergeych.lyng.Pos, len: Int) = src.offsetOf(p).let { s -> offset >= s && offset < s + len }
for (d in mini.declarations) {
if (matches(d.nameStart, d.name.length)) {
val kind = when (d) {
is MiniFunDecl -> "Function"
is MiniClassDecl -> "Class"
is MiniEnumDecl -> "Enum"
is MiniValDecl -> if (d.mutable) "Variable" else "Value"
}
return d.name to kind
}
val members = when (d) {
is MiniFunDecl -> {
for (p in d.params) {
if (matches(p.nameStart, p.name.length)) return p.name to "Parameter"
}
emptyList()
}
is MiniEnumDecl -> {
// Enum entries don't have explicit nameStart yet, but we can check their text range if we had it.
// For now, heuristic: if offset is within enum range and matches an entry name.
// To be more precise, we should check that we are NOT at the 'enum' or enum name.
if (offset >= src.offsetOf(d.range.start) && offset <= src.offsetOf(d.range.end)) {
if (d.entries.contains(name) && !matches(d.nameStart, d.name.length)) {
// verify we are actually at the entry word in text
val off = src.offsetOf(d.range.start)
val end = src.offsetOf(d.range.end)
val text = src.text.substring(off, end)
// This is still a bit loose but better
return name to "EnumConstant"
}
}
emptyList()
}
is MiniClassDecl -> {
for (cf in d.ctorFields) {
if (matches(cf.nameStart, cf.name.length)) return cf.name to (if (cf.mutable) "Variable" else "Value")
}
for (cf in d.classFields) {
if (matches(cf.nameStart, cf.name.length)) return cf.name to (if (cf.mutable) "Variable" else "Value")
}
d.members
}
else -> emptyList()
}
for (m in members) {
if (matches(m.nameStart, m.name.length)) {
val kind = when (m) {
is MiniMemberFunDecl -> "Function"
is MiniMemberValDecl -> if (m.isStatic) "Value" else (if (m.mutable) "Variable" else "Value")
is MiniInitDecl -> "Initializer"
}
return m.name to kind
}
}
}
return null
}
fun findTypeByRange(mini: MiniScript?, name: String, startOffset: Int): MiniTypeRef? {
if (mini == null) return null
val src = mini.range.start.source
for (d in mini.declarations) {
if (d.name == name && src.offsetOf(d.nameStart) == startOffset) {
return when (d) {
is MiniValDecl -> d.type
is MiniFunDecl -> d.returnType
else -> null
}
}
if (d is MiniFunDecl) {
for (p in d.params) {
if (p.name == name && src.offsetOf(p.nameStart) == startOffset) return p.type
}
}
if (d is MiniClassDecl) {
for (m in d.members) {
if (m.name == name && src.offsetOf(m.nameStart) == startOffset) {
return when (m) {
is MiniMemberFunDecl -> m.returnType
is MiniMemberValDecl -> m.type
else -> null
}
}
}
for (cf in d.ctorFields) {
if (cf.name == name && src.offsetOf(cf.nameStart) == startOffset) return cf.type
}
for (cf in d.classFields) {
if (cf.name == name && src.offsetOf(cf.nameStart) == startOffset) return cf.type
}
}
}
return null
}
/** /**
* Convert MiniAst imports to fully-qualified module names expected by BuiltinDocRegistry. * Convert MiniAst imports to fully-qualified module names expected by BuiltinDocRegistry.
* Heuristics: * Heuristics:
* - If an import does not start with "lyng.", prefix it with "lyng." (e.g., "io.fs" -> "lyng.io.fs"). * - If an import does not start with "lyng.", prefix it with "lyng." (e.g., "io.fs" -> "lyng.io.fs").
* - Keep original if it already starts with "lyng.". * - Keep original if it already starts with "lyng.".
* - Always include "lyng.stdlib" to make builtins available for docs. * - Always include "lyng.stdlib" to make builtins available for docs.
* - If [sourceText] is provided, uses textual extraction as a fallback if MiniAst has no imports.
*/ */
fun canonicalImportedModules(mini: MiniScript): List<String> { fun canonicalImportedModules(mini: MiniScript, sourceText: String? = null): List<String> {
val raw = mini.imports.map { it.segments.joinToString(".") { s -> s.name } } val raw = mini.imports.map { it.segments.joinToString(".") { s -> s.name } }
val result = LinkedHashSet<String>() val result = LinkedHashSet<String>()
for (name in raw) { for (name in raw) {
val canon = if (name.startsWith("lyng.")) name else "lyng.$name" val canon = if (name.startsWith("lyng.")) name else "lyng.$name"
result.add(canon) result.add(canon)
} }
if (result.isEmpty() && sourceText != null) {
result.addAll(extractImportsFromText(sourceText))
}
// Always make stdlib available as a fallback context for common types, // Always make stdlib available as a fallback context for common types,
// even when there are no explicit imports in the file // even when there are no explicit imports in the file
result.add("lyng.stdlib") result.add("lyng.stdlib")
return result.toList() return result.toList()
} }
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()
}
fun aggregateClasses(importedModules: List<String>, localMini: MiniScript? = null): Map<String, MiniClassDecl> { fun aggregateClasses(importedModules: List<String>, localMini: MiniScript? = null): Map<String, MiniClassDecl> {
// Collect all class decls by name across modules, then merge duplicates by unioning members and bases. // Collect all class decls by name across modules, then merge duplicates by unioning members and bases.
val buckets = LinkedHashMap<String, MutableList<MiniClassDecl>>() val buckets = LinkedHashMap<String, MutableList<MiniClassDecl>>()
@ -50,7 +179,7 @@ object DocLookupUtils {
buckets.getOrPut(cls.name) { mutableListOf() }.add(cls) buckets.getOrPut(cls.name) { mutableListOf() }.add(cls)
} }
for (en in docs.filterIsInstance<MiniEnumDecl>()) { for (en in docs.filterIsInstance<MiniEnumDecl>()) {
buckets.getOrPut(en.name) { mutableListOf() }.add(en.toSyntheticClass()) buckets.getOrPut(en.name) { mutableListOf() }.add(enumToSyntheticClass(en))
} }
} }
@ -61,7 +190,7 @@ object DocLookupUtils {
buckets.getOrPut(d.name) { mutableListOf() }.add(d) buckets.getOrPut(d.name) { mutableListOf() }.add(d)
} }
is MiniEnumDecl -> { is MiniEnumDecl -> {
val syn = d.toSyntheticClass() val syn = enumToSyntheticClass(d)
buckets.getOrPut(d.name) { mutableListOf() }.add(syn) buckets.getOrPut(d.name) { mutableListOf() }.add(syn)
} }
else -> {} else -> {}
@ -140,10 +269,6 @@ object DocLookupUtils {
*/ */
fun guessClassFromCallBefore(text: String, dotPos: Int, importedModules: List<String>, localMini: MiniScript? = null): String? { fun guessClassFromCallBefore(text: String, dotPos: Int, importedModules: List<String>, localMini: MiniScript? = null): String? {
var i = (dotPos - 1).coerceAtLeast(0) var i = (dotPos - 1).coerceAtLeast(0)
// Skip spaces
while (i >= 0 && text[i].isWhitespace()) i++
// Note: the previous line is wrong direction; correct implementation below
i = (dotPos - 1).coerceAtLeast(0)
while (i >= 0 && text[i].isWhitespace()) i-- while (i >= 0 && text[i].isWhitespace()) i--
if (i < 0 || text[i] != ')') return null if (i < 0 || text[i] != ')') return null
// Walk back to matching '(' accounting nested parentheses // Walk back to matching '(' accounting nested parentheses
@ -172,27 +297,391 @@ object DocLookupUtils {
return if (classes.containsKey(name)) name else null return if (classes.containsKey(name)) name else null
} }
private fun isIdentChar(c: Char): Boolean = c == '_' || c.isLetterOrDigit() fun guessReceiverClassViaMini(mini: MiniScript?, text: String, dotPos: Int, imported: List<String>, binding: BindingSnapshot? = null): String? {
if (mini == null) return null
val i = prevNonWs(text, dotPos - 1)
if (i < 0) return null
val wordRange = wordRangeAt(text, i + 1) ?: return null
val ident = text.substring(wordRange.first, wordRange.second)
private fun MiniEnumDecl.toSyntheticClass(): MiniClassDecl { // 0) Use binding if available for precise resolution
if (binding != null) {
val ref = binding.references.firstOrNull { wordRange.first >= it.start && wordRange.first < it.end }
if (ref != null) {
val sym = binding.symbols.firstOrNull { it.id == ref.symbolId }
if (sym != null) {
val type = findTypeByRange(mini, sym.name, sym.declStart)
simpleClassNameOf(type)?.let { return it }
}
} else {
// Check if it's a declaration (e.g. static access to a class)
val sym = binding.symbols.firstOrNull { it.declStart == wordRange.first && it.name == ident }
if (sym != null) {
val type = findTypeByRange(mini, sym.name, sym.declStart)
simpleClassNameOf(type)?.let { return it }
// if it's a class/enum, return its name directly
if (sym.kind == net.sergeych.lyng.binding.SymbolKind.Class || sym.kind == net.sergeych.lyng.binding.SymbolKind.Enum) return sym.name
}
}
}
// 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 fallback without scope mapping)
for (fd in mini.declarations.filterIsInstance<MiniFunDecl>()) {
for (p in fd.params) {
if (p.name == ident) return simpleClassNameOf(p.type)
}
}
// 3) Recursive chaining: Base.ident.
val dotBefore = findDotLeft(text, wordRange.first)
if (dotBefore != null) {
val receiverClass = guessReceiverClassViaMini(mini, text, dotBefore, imported, binding)
?: guessReceiverClass(text, dotBefore, imported, mini)
if (receiverClass != null) {
val resolved = 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 = aggregateClasses(imported, mini)
if (classes.containsKey(ident)) return ident
return null
}
fun guessReturnClassFromMemberCallBeforeMini(mini: MiniScript?, text: String, dotPos: Int, imported: List<String>, binding: BindingSnapshot? = null): String? {
if (mini == null) return null
var i = 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 && 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, binding) ?: 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)
val sig = sigs[callee]
if (sig != null && sig.typeText != null) return sig.typeText
}
}
// Else fallback to registry-based resolution (covers imported classes)
return 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)
}
}
data class ScannedSig(val kind: String, val params: List<String>?, val typeText: String?)
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() }
if (!map.containsKey(name)) map[name] = ScannedSig(kind, null, type)
}
return map
}
fun guessReceiverClass(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
guessClassFromCallBefore(text, dotPos, imported, mini)?.let { return it }
var i = prevNonWs(text, dotPos - 1)
if (i >= 0) {
when (text[i]) {
'"' -> return "String"
']' -> return "List"
'}' -> return "Dict"
')' -> {
// Parenthesized expression: walk back to matching '(' and inspect the 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"
}
}
}
}
// Numeric literal: decimal/int/hex/scientific
var j = i
var hasDigits = false
var hasDot = false
var hasExp = false
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--; if (j >= 0 && (text[j] == '+' || text[j] == '-')) j--; continue
}
if (ch in listOf('x', 'X', 'a', 'b', 'c', 'd', 'f', 'A', 'B', 'C', 'D', 'F')) {
j--; continue
}
break
}
if (hasDigits) return if (hasDot || hasExp) "Real" else "Int"
// 3) this@Type or as Type
val identRange = wordRangeAt(text, i + 1)
if (identRange != null) {
val ident = text.substring(identRange.first, identRange.second)
// if it's "as Type", we want Type
var k = prevNonWs(text, identRange.first - 1)
if (k >= 1 && text[k] == 's' && text[k - 1] == 'a' && (k - 1 == 0 || !text[k - 2].isLetterOrDigit())) {
return ident
}
// if it's "this@Type", we want Type
if (k >= 0 && text[k] == '@') {
val k2 = prevNonWs(text, k - 1)
if (k2 >= 3 && text.substring(k2 - 3, k2 + 1) == "this") {
return ident
}
}
// 4) Check if it's a known class (static access)
val classes = aggregateClasses(imported, mini)
if (classes.containsKey(ident)) return ident
}
}
return null
}
fun guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
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 && isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
var k = start - 1
while (k >= 0 && text[k].isWhitespace()) k--
if (k < 0 || text[k] != '.') return null
val prevDot = k
val receiverClass = guessReceiverClass(text, prevDot, imported, mini) ?: return null
val resolved = resolveMemberWithInheritance(imported, receiverClass, callee, mini) ?: return null
val member = resolved.second
val ret = when (member) {
is MiniMemberFunDecl -> member.returnType
is MiniMemberValDecl -> member.type
is MiniInitDecl -> null
}
return simpleClassNameOf(ret)
}
fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
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 && isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
var k = start - 1
while (k >= 0 && text[k].isWhitespace()) k--
if (k >= 0 && text[k] == '.') return null // was a member call
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
}
fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
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 && isIdentChar(text[j])) j--
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
val resolved = findMemberAcrossClasses(imported, callee, mini) ?: return null
val member = resolved.second
val ret = when (member) {
is MiniMemberFunDecl -> member.returnType
is MiniMemberValDecl -> member.type
is MiniInitDecl -> null
}
return simpleClassNameOf(ret)
}
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
}
fun findDotLeft(text: String, offset: Int): Int? {
var i = (offset - 1).coerceAtLeast(0)
while (i >= 0 && text[i].isWhitespace()) i--
return if (i >= 0 && text[i] == '.') i else null
}
fun prevNonWs(text: String, start: Int): Int {
var i = start.coerceAtMost(text.length - 1)
while (i >= 0 && text[i].isWhitespace()) i--
return i
}
fun wordRangeAt(text: String, offset: Int): Pair<Int, Int>? {
if (text.isEmpty()) return null
val off = offset.coerceIn(0, text.length)
var s = off
var e = off
while (s > 0 && isIdentChar(text[s - 1])) s--
while (e < text.length && isIdentChar(text[e])) e++
return if (s < e) s to e else null
}
fun isIdentChar(c: Char): Boolean = c == '_' || c.isLetterOrDigit()
fun enumToSyntheticClass(en: MiniEnumDecl): MiniClassDecl {
val staticMembers = mutableListOf<MiniMemberDecl>() val staticMembers = mutableListOf<MiniMemberDecl>()
// entries: List // entries: List
staticMembers.add(MiniMemberValDecl(range, "entries", false, null, null, nameStart, isStatic = true)) staticMembers.add(MiniMemberValDecl(en.range, "entries", false, null, null, en.nameStart, isStatic = true))
// valueOf(name: String): Enum // valueOf(name: String): Enum
staticMembers.add(MiniMemberFunDecl(range, "valueOf", listOf(MiniParam("name", null, nameStart)), null, null, nameStart, isStatic = true)) staticMembers.add(MiniMemberFunDecl(en.range, "valueOf", listOf(MiniParam("name", null, en.nameStart)), null, null, en.nameStart, isStatic = true))
// Also add each entry as a static member (const) // Also add each entry as a static member (const)
for (entry in entries) { for (entry in en.entries) {
staticMembers.add(MiniMemberValDecl(range, entry, false, MiniTypeName(range, listOf(MiniTypeName.Segment(name, range)), false), null, nameStart, isStatic = true)) staticMembers.add(MiniMemberValDecl(en.range, entry, false, MiniTypeName(en.range, listOf(MiniTypeName.Segment(en.name, en.range)), false), null, en.nameStart, isStatic = true))
} }
return MiniClassDecl( return MiniClassDecl(
range = range, range = en.range,
name = name, name = en.name,
bases = listOf("Enum"), bases = listOf("Enum"),
bodyRange = null, bodyRange = null,
doc = doc, doc = en.doc,
nameStart = nameStart, nameStart = en.nameStart,
members = staticMembers members = staticMembers
) )
} }

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"); * 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.
@ -49,10 +49,10 @@ class BindingHighlightTest {
val binding = Binder.bind(text, mini!!) val binding = Binder.bind(text, mini!!)
// Find the top-level symbol for counter and ensure it is mutable (Var) // Find the top-level symbol for counter and ensure it is mutable (Variable)
val sym = binding.symbols.firstOrNull { it.name == "counter" } val sym = binding.symbols.firstOrNull { it.name == "counter" }
assertNotNull(sym, "Top-level var 'counter' must be registered as a symbol") assertNotNull(sym, "Top-level var 'counter' must be registered as a symbol")
assertEquals(SymbolKind.Var, sym.kind, "'counter' declared with var should be SymbolKind.Var") assertEquals(SymbolKind.Variable, sym.kind, "'counter' declared with var should be SymbolKind.Variable")
// Declaration position // Declaration position
val declRange = sym.declStart to sym.declEnd val declRange = sym.declStart to sym.declEnd
@ -82,7 +82,7 @@ class BindingHighlightTest {
val sym = binding.symbols.firstOrNull { it.name == "answer" } val sym = binding.symbols.firstOrNull { it.name == "answer" }
assertNotNull(sym, "Top-level val 'answer' must be registered as a symbol") assertNotNull(sym, "Top-level val 'answer' must be registered as a symbol")
assertEquals(SymbolKind.Val, sym.kind, "'answer' declared with val should be SymbolKind.Val") assertEquals(SymbolKind.Value, sym.kind, "'answer' declared with val should be SymbolKind.Value")
val declRange = sym.declStart to sym.declEnd val declRange = sym.declStart to sym.declEnd
val refs = binding.references.filter { it.symbolId == sym.id && (it.start to it.end) != declRange } val refs = binding.references.filter { it.symbolId == sym.id && (it.start to it.end) != declRange }
@ -119,7 +119,7 @@ class BindingHighlightTest {
// Ensure we registered the local var/val symbol for `name` // Ensure we registered the local var/val symbol for `name`
val nameSym = binding.symbols.firstOrNull { it.name == "name" } val nameSym = binding.symbols.firstOrNull { it.name == "name" }
assertNotNull(nameSym, "Local variable 'name' should be registered as a symbol") assertNotNull(nameSym, "Local variable 'name' should be registered as a symbol")
assertEquals(SymbolKind.Var, nameSym.kind, "'name' is declared with var and must be SymbolKind.Var") assertEquals(SymbolKind.Variable, nameSym.kind, "'name' is declared with var and must be SymbolKind.Variable")
// Ensure there is at least one usage reference to `name` (not just the declaration) // Ensure there is at least one usage reference to `name` (not just the declaration)
val nameRefs = binding.references.filter { it.symbolId == nameSym.id } val nameRefs = binding.references.filter { it.symbolId == nameSym.id }
@ -165,7 +165,7 @@ class BindingHighlightTest {
val binding = Binder.bind(text, mini!!) val binding = Binder.bind(text, mini!!)
val nameSym = binding.symbols.firstOrNull { it.name == "name" && (it.kind == SymbolKind.Var || it.kind == SymbolKind.Val) } val nameSym = binding.symbols.firstOrNull { it.name == "name" && (it.kind == SymbolKind.Variable || it.kind == SymbolKind.Value) }
assertNotNull(nameSym, "Local variable 'name' should be registered as a symbol") assertNotNull(nameSym, "Local variable 'name' should be registered as a symbol")
// Find the specific usage inside string-literal invocation: "%s is directory"(name) // Find the specific usage inside string-literal invocation: "%s is directory"(name)

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"); * 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.
@ -49,7 +49,7 @@ class BindingTest {
} }
""" """
) )
// Expect at least one Param symbol "a" and one Val symbol "x" // Expect at least one Parameter symbol "a" and one Value symbol "x"
val aIds = snap.symbols.filter { it.name == "a" }.map { it.id } val aIds = snap.symbols.filter { it.name == "a" }.map { it.id }
val xIds = snap.symbols.filter { it.name == "x" }.map { it.id } val xIds = snap.symbols.filter { it.name == "x" }.map { it.id }
assertTrue(aIds.isNotEmpty()) assertTrue(aIds.isNotEmpty())

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"); * 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.
@ -420,9 +420,9 @@ suspend fun applyLyngHighlightToTextAst(text: String): String {
fun classForKind(k: SymbolKind): String? = when (k) { fun classForKind(k: SymbolKind): String? = when (k) {
SymbolKind.Function -> "hl-fn" SymbolKind.Function -> "hl-fn"
SymbolKind.Class, SymbolKind.Enum -> "hl-class" SymbolKind.Class, SymbolKind.Enum -> "hl-class"
SymbolKind.Param -> "hl-param" SymbolKind.Parameter -> "hl-param"
SymbolKind.Val -> "hl-val" SymbolKind.Value -> "hl-val"
SymbolKind.Var -> "hl-var" SymbolKind.Variable -> "hl-var"
} }
for (ref in binding.references) { for (ref in binding.references) {
val key = ref.start to ref.end val key = ref.start to ref.end