From bc6613ec017786ba12f106b9b1faef8cdc0cf8e4 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sat, 3 Jan 2026 11:59:50 +0100 Subject: [PATCH] attempt to add navigation to plugin (partial success) --- lyng-idea/build.gradle.kts | 4 +- .../idea/annotators/LyngExternalAnnotator.kt | 6 +- .../completion/LyngCompletionContributor.kt | 490 +--------------- .../idea/docs/LyngDocumentationProvider.kt | 246 ++++---- .../lyng/idea/editor/LyngEnterHandler.kt | 53 +- .../lyng/idea/editor/LyngTypedHandler.kt | 57 +- .../idea/format/LyngLineIndentProvider.kt | 29 +- .../idea/navigation/LyngDeclarationElement.kt | 109 ++++ .../idea/navigation/LyngFindUsagesProvider.kt | 92 +++ .../navigation/LyngGotoDeclarationHandler.kt | 58 ++ .../lyng/idea/navigation/LyngIconProvider.kt | 48 ++ .../lyng/idea/navigation/LyngPsiReference.kt | 204 +++++++ .../navigation/LyngPsiReferenceContributor.kt | 54 ++ .../lyng/idea/util/FormattingUtils.kt | 62 +++ .../sergeych/lyng/idea/util/LyngAstManager.kt | 101 ++++ .../src/main/resources/META-INF/plugin.xml | 8 +- .../kotlin/net/sergeych/lyng/Compiler.kt | 2 +- .../net/sergeych/lyng/binding/Binder.kt | 12 +- .../lyng/miniast/CompletionEngineLight.kt | 282 +--------- .../sergeych/lyng/miniast/DocLookupUtils.kt | 523 +++++++++++++++++- .../commonTest/kotlin/BindingHighlightTest.kt | 12 +- lynglib/src/commonTest/kotlin/BindingTest.kt | 4 +- .../kotlin/net/sergeych/lyngweb/Highlight.kt | 8 +- 23 files changed, 1498 insertions(+), 966 deletions(-) create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngDeclarationElement.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngFindUsagesProvider.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngGotoDeclarationHandler.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngIconProvider.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngPsiReference.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngPsiReferenceContributor.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/FormattingUtils.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/LyngAstManager.kt diff --git a/lyng-idea/build.gradle.kts b/lyng-idea/build.gradle.kts index c0b49bc..c3296f1 100644 --- a/lyng-idea/build.gradle.kts +++ b/lyng-idea/build.gradle.kts @@ -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) diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt index 48b9010..7139d3d 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt @@ -185,9 +185,9 @@ class LyngExternalAnnotator : ExternalAnnotator 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 diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionContributor.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionContributor.kt index 4127d07..9c0bf84 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionContributor.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionContributor.kt @@ -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? { - 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() - .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? { - 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().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?, val typeText: String?) - - private fun scanLocalClassMembersFromText(mini: MiniScript, text: String, cls: MiniClassDecl): Map { - 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() - // 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, 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().` → detects `lines()` on receiver class `Path` and returns `Iterator`. - */ - private fun guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List, 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().`. - * We extract callee name and resolve it among imported modules' top-level functions. - */ - private fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List, 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().firstOrNull { it.name == callee } - if (fn != null) return simpleClassNameOf(fn.returnType) - } - - // Also check local declarations - mini?.declarations?.filterIsInstance()?.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().` where `something` type is unknown, but `lines()` commonly returns Iterator. - */ - private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List, 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). */ - 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("", 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("lyng.mini.cache") - private val STAMP_KEY = Key.create("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 diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt index 2315752..8c7a056 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt @@ -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("", 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 "
enum constant ${d.name}.${name}
" + } + } } - } - // 2) Check parameters of functions - if (haveMini) for (fn in mini.declarations.filterIsInstance()) { - 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()) { + 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()) { - 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()) { + 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()) { + 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()) { - 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 { - val result = LinkedHashSet() - 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("
").append(htmlEscape(title)).append("
") - 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("
").append(htmlEscape(title)).append("
") - 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("
").append(htmlEscape(title)).append("
") - if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc!!)) + if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc)) return sb.toString() } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngEnterHandler.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngEnterHandler.kt index 2f5e115..9136ea3 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngEnterHandler.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngEnterHandler.kt @@ -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)) diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngTypedHandler.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngTypedHandler.kt index 6c89350..a22e9c4 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngTypedHandler.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/editor/LyngTypedHandler.kt @@ -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 } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngLineIndentProvider.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngLineIndentProvider.kt index dc1cc1d..38f32f9 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngLineIndentProvider.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/format/LyngLineIndentProvider.kt @@ -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) - } } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngDeclarationElement.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngDeclarationElement.kt new file mode 100644 index 0000000..8ae8ab7 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngDeclarationElement.kt @@ -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 + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngFindUsagesProvider.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngFindUsagesProvider.kt new file mode 100644 index 0000000..db9a149 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngFindUsagesProvider.kt @@ -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" + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngGotoDeclarationHandler.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngGotoDeclarationHandler.kt new file mode 100644 index 0000000..3121793 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngGotoDeclarationHandler.kt @@ -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? { + if (sourceElement == null) return null + + val allTargets = mutableListOf() + + // 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() + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngIconProvider.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngIconProvider.kt new file mode 100644 index 0000000..3aac7e2 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngIconProvider.kt @@ -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 + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngPsiReference.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngPsiReference.kt new file mode 100644 index 0000000..55660c3 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngPsiReference.kt @@ -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(element, TextRange(0, element.textLength)) { + + override fun multiResolve(incompleteCode: Boolean): Array { + val file = element.containingFile + val text = file.text + val offset = element.textRange.startOffset + val name = element.text ?: "" + val results = mutableListOf() + + 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() + 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 { + val results = mutableListOf() + 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 = emptyArray() +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngPsiReferenceContributor.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngPsiReferenceContributor.kt new file mode 100644 index 0000000..c857590 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngPsiReferenceContributor.kt @@ -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 { + 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 + } + } + ) + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/FormattingUtils.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/FormattingUtils.kt new file mode 100644 index 0000000..1dbdabe --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/FormattingUtils.kt @@ -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 + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/LyngAstManager.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/LyngAstManager.kt new file mode 100644 index 0000000..496ce18 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/LyngAstManager.kt @@ -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("lyng.mini.cache") + private val BINDING_KEY = Key.create("lyng.binding.cache") + private val STAMP_KEY = Key.create("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 + } +} diff --git a/lyng-idea/src/main/resources/META-INF/plugin.xml b/lyng-idea/src/main/resources/META-INF/plugin.xml index 4f85666..e97bd30 100644 --- a/lyng-idea/src/main/resources/META-INF/plugin.xml +++ b/lyng-idea/src/main/resources/META-INF/plugin.xml @@ -1,5 +1,5 @@ + + + + + + diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 77b5280..c06670b 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -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 diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt index 07b3fb1..705e54f 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt @@ -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 diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt index c3d4b54..74230b2 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt @@ -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 = mini?.let { DocLookupUtils.canonicalImportedModules(it) } ?: emptyList() - val fromText: List = extractImportsFromText(text) - val imported: List = LinkedHashSet().apply { - fromMini.forEach { add(it) } - fromText.forEach { add(it) } - add("lyng.stdlib") - }.toList() + val imported: List = DocLookupUtils.canonicalImportedModules(mini ?: return emptyList(), text) val cap = 200 val out = ArrayList(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? { - 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, 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, 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, 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().firstOrNull { it.name == callee } - if (fn != null) return simpleClassNameOf(fn.returnType) - } - // Also check local declarations - mini?.declarations?.filterIsInstance()?.firstOrNull { it.name == callee }?.let { return simpleClassNameOf(it.returnType) } - return null - } - - private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List, 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? { - 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 { - val result = LinkedHashSet() - 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() - } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt index 0e42a42..d098493 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt @@ -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? { + 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 { + fun canonicalImportedModules(mini: MiniScript, sourceText: String? = null): List { val raw = mini.imports.map { it.segments.joinToString(".") { s -> s.name } } val result = LinkedHashSet() 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 { + val result = LinkedHashSet() + 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, localMini: MiniScript? = null): Map { // Collect all class decls by name across modules, then merge duplicates by unioning members and bases. val buckets = LinkedHashMap>() @@ -50,7 +179,7 @@ object DocLookupUtils { buckets.getOrPut(cls.name) { mutableListOf() }.add(cls) } for (en in docs.filterIsInstance()) { - 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, 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, 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()) { + 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, 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().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?, val typeText: String?) + + fun scanLocalClassMembersFromText(mini: MiniScript, text: String, cls: MiniClassDecl): Map { + 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() + // 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, 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, 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, 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().firstOrNull { it.name == callee } + if (fn != null) return simpleClassNameOf(fn.returnType) + } + // Also check local declarations + mini?.declarations?.filterIsInstance()?.firstOrNull { it.name == callee }?.let { return simpleClassNameOf(it.returnType) } + return null + } + + fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List, 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? { + 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() // 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 ) } diff --git a/lynglib/src/commonTest/kotlin/BindingHighlightTest.kt b/lynglib/src/commonTest/kotlin/BindingHighlightTest.kt index 35f5400..7d19383 100644 --- a/lynglib/src/commonTest/kotlin/BindingHighlightTest.kt +++ b/lynglib/src/commonTest/kotlin/BindingHighlightTest.kt @@ -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) diff --git a/lynglib/src/commonTest/kotlin/BindingTest.kt b/lynglib/src/commonTest/kotlin/BindingTest.kt index 4963446..a160f9c 100644 --- a/lynglib/src/commonTest/kotlin/BindingTest.kt +++ b/lynglib/src/commonTest/kotlin/BindingTest.kt @@ -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()) diff --git a/lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Highlight.kt b/lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Highlight.kt index 3daabd3..b6d11a0 100644 --- a/lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Highlight.kt +++ b/lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Highlight.kt @@ -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