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