diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/LyngFileTypeFactory.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/LyngFileTypeFactory.kt new file mode 100644 index 0000000..55bce83 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/LyngFileTypeFactory.kt @@ -0,0 +1,33 @@ +/* + * 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 + +import com.intellij.openapi.fileTypes.FileTypeConsumer +import com.intellij.openapi.fileTypes.FileTypeFactory +import com.intellij.openapi.fileTypes.WildcardFileNameMatcher + +/** + * Legacy way to register file type matchers, used here to robustly match *.lyng.d + * without conflicting with standard .d extensions from other plugins. + */ +@Suppress("DEPRECATION") +class LyngFileTypeFactory : FileTypeFactory() { + override fun createFileTypes(consumer: FileTypeConsumer) { + // Register the multi-dot pattern explicitly + consumer.consume(LyngFileType, WildcardFileNameMatcher("*.lyng.d")) + } +} 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 5cdf63d..4176f0e 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 @@ -25,9 +25,6 @@ import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.util.Key import com.intellij.openapi.util.TextRange import com.intellij.psi.PsiFile -import kotlinx.coroutines.runBlocking -import net.sergeych.lyng.Compiler -import net.sergeych.lyng.ScriptError import net.sergeych.lyng.Source import net.sergeych.lyng.binding.Binder import net.sergeych.lyng.binding.SymbolKind @@ -35,7 +32,7 @@ import net.sergeych.lyng.highlight.HighlightKind import net.sergeych.lyng.highlight.SimpleLyngHighlighter import net.sergeych.lyng.highlight.offsetOf import net.sergeych.lyng.idea.highlight.LyngHighlighterColors -import net.sergeych.lyng.idea.util.IdeLenientImportProvider +import net.sergeych.lyng.idea.util.LyngAstManager import net.sergeych.lyng.miniast.* /** @@ -43,7 +40,7 @@ import net.sergeych.lyng.miniast.* * and applies semantic highlighting comparable with the web highlighter. */ class LyngExternalAnnotator : ExternalAnnotator() { - data class Input(val text: String, val modStamp: Long, val previousSpans: List?) + data class Input(val text: String, val modStamp: Long, val previousSpans: List?, val file: PsiFile) data class Span(val start: Int, val end: Int, val key: com.intellij.openapi.editor.colors.TextAttributesKey) data class Error(val start: Int, val end: Int, val message: String) @@ -55,43 +52,24 @@ class LyngExternalAnnotator : ExternalAnnotator", text) - try { - // Call suspend API from blocking context - val provider = IdeLenientImportProvider.create() - runBlocking { Compiler.compileWithMini(source, provider, sink) } - } catch (e: Throwable) { - if (e is com.intellij.openapi.progress.ProcessCanceledException) throw e - // On script parse error: keep previous spans and report the error location - if (e is ScriptError) { - val off = try { source.offsetOf(e.pos) } catch (_: Throwable) { -1 } - val start0 = off.coerceIn(0, text.length.coerceAtLeast(0)) - val (start, end) = expandErrorRange(text, start0) - // Fast fix (5): clear cached highlighting after the error start position - val trimmed = collectedInfo.previousSpans?.filter { it.end <= start } ?: emptyList() - return Result( - collectedInfo.modStamp, - trimmed, - Error(start, end, e.errorMessage) - ) - } - // Other failures: keep previous spans without error - return Result(collectedInfo.modStamp, collectedInfo.previousSpans ?: emptyList(), null) - } + + // Use LyngAstManager to get the (potentially merged) Mini-AST + val mini = LyngAstManager.getMiniAst(collectedInfo.file) + ?: return Result(collectedInfo.modStamp, collectedInfo.previousSpans ?: emptyList()) + ProgressManager.checkCanceled() - val mini = sink.build() ?: return Result(collectedInfo.modStamp, collectedInfo.previousSpans ?: emptyList()) - + val source = Source(collectedInfo.file.name, text) + val out = ArrayList(256) fun isFollowedByParenOrBlock(rangeEnd: Int): Boolean { @@ -118,7 +96,8 @@ class LyngExternalAnnotator : ExternalAnnotator + if (d.nameStart.source != source) return@forEach when (d) { is MiniFunDecl -> putName(d.nameStart, d.name, LyngHighlighterColors.FUNCTION_DECLARATION) is MiniClassDecl -> putName(d.nameStart, d.name, LyngHighlighterColors.TYPE) @@ -132,19 +111,22 @@ class LyngExternalAnnotator : ExternalAnnotator + if (imp.range.start.source != source) return@forEach + imp.segments.forEach { seg -> putMiniRange(seg.range, LyngHighlighterColors.NAMESPACE) } } // Parameters - for (fn in mini.declarations.filterIsInstance()) { - for (p in fn.params) putName(p.nameStart, p.name, LyngHighlighterColors.PARAMETER) + mini.declarations.filterIsInstance().forEach { fn -> + if (fn.nameStart.source != source) return@forEach + fn.params.forEach { p -> putName(p.nameStart, p.name, LyngHighlighterColors.PARAMETER) } } // Type name segments (including generics base & args) fun addTypeSegments(t: MiniTypeRef?) { when (t) { is MiniTypeName -> t.segments.forEach { seg -> + if (seg.range.start.source != source) return@forEach val s = source.offsetOf(seg.range.start) putRange(s, (s + seg.name.length).coerceAtMost(text.length), LyngHighlighterColors.TYPE) } @@ -158,12 +140,14 @@ class LyngExternalAnnotator : ExternalAnnotator { /* name is in range; could be highlighted as TYPE as well */ - putMiniRange(t.range, LyngHighlighterColors.TYPE) + if (t.range.start.source == source) + putMiniRange(t.range, LyngHighlighterColors.TYPE) } null -> {} } } - for (d in mini.declarations) { + mini.declarations.forEach { d -> + if (d.nameStart.source != source) return@forEach when (d) { is MiniFunDecl -> { addTypeSegments(d.returnType) @@ -190,7 +174,7 @@ class LyngExternalAnnotator : ExternalAnnotator>(binding.symbols.size * 2) - for (sym in binding.symbols) declKeys += (sym.declStart to sym.declEnd) + binding.symbols.forEach { sym -> declKeys += (sym.declStart to sym.declEnd) } fun keyForKind(k: SymbolKind) = when (k) { SymbolKind.Function -> LyngHighlighterColors.FUNCTION @@ -203,13 +187,16 @@ class LyngExternalAnnotator : ExternalAnnotator>() - for (ref in binding.references) { + binding.references.forEach { ref -> val key = ref.start to ref.end - if (declKeys.contains(key)) continue - val sym = binding.symbols.firstOrNull { it.id == ref.symbolId } ?: continue - val color = keyForKind(sym.kind) - putRange(ref.start, ref.end, color) - covered += key + if (!declKeys.contains(key)) { + val sym = binding.symbols.firstOrNull { it.id == ref.symbolId } + if (sym != null) { + val color = keyForKind(sym.kind) + putRange(ref.start, ref.end, color) + covered += key + } + } } // Heuristics on top of binder: function call-sites and simple name-based roles @@ -219,32 +206,41 @@ class LyngExternalAnnotator : ExternalAnnotator role map for top-level vals/vars and parameters val nameRole = HashMap(8) - for (d in mini.declarations) when (d) { - is MiniValDecl -> nameRole[d.name] = if (d.mutable) LyngHighlighterColors.VARIABLE else LyngHighlighterColors.VALUE - is MiniFunDecl -> d.params.forEach { p -> nameRole[p.name] = LyngHighlighterColors.PARAMETER } - else -> {} + mini.declarations.forEach { d -> + when (d) { + is MiniValDecl -> nameRole[d.name] = + if (d.mutable) LyngHighlighterColors.VARIABLE else LyngHighlighterColors.VALUE + + is MiniFunDecl -> d.params.forEach { p -> nameRole[p.name] = LyngHighlighterColors.PARAMETER } + else -> {} + } } - for (s in tokens) if (s.kind == HighlightKind.Identifier) { - val start = s.range.start - val end = s.range.endExclusive - val key = start to end - if (key in covered || key in declKeys) continue - - // Call-site detection first so it wins over var/param role - if (isFollowedByParenOrBlock(end)) { - putRange(start, end, LyngHighlighterColors.FUNCTION) - covered += key - continue - } - - // Simple role by known names - val ident = try { text.substring(start, end) } catch (_: Throwable) { null } - if (ident != null) { - val roleKey = nameRole[ident] - if (roleKey != null) { - putRange(start, end, roleKey) - covered += key + tokens.forEach { s -> + if (s.kind == HighlightKind.Identifier) { + val start = s.range.start + val end = s.range.endExclusive + val key = start to end + if (key !in covered && key !in declKeys) { + // Call-site detection first so it wins over var/param role + if (isFollowedByParenOrBlock(end)) { + putRange(start, end, LyngHighlighterColors.FUNCTION) + covered += key + } else { + // Simple role by known names + val ident = try { + text.substring(start, end) + } catch (_: Throwable) { + null + } + if (ident != null) { + val roleKey = nameRole[ident] + if (roleKey != null) { + putRange(start, end, roleKey) + covered += key + } + } + } } } } @@ -256,31 +252,37 @@ class LyngExternalAnnotator : ExternalAnnotator putRange(start, end, LyngHighlighterColors.LABEL) - lexeme.startsWith("@") -> { - // Try to see if it's an exit label - val prevNonWs = prevNonWs(text, start) - val prevWord = if (prevNonWs >= 0) { - var wEnd = prevNonWs + 1 - var wStart = prevNonWs - while (wStart > 0 && text[wStart - 1].isLetter()) wStart-- - text.substring(wStart, wEnd) - } else null - - if (prevWord in setOf("return", "break", "continue") || isFollowedByParenOrBlock(end)) { - putRange(start, end, LyngHighlighterColors.LABEL) - } else { - putRange(start, end, LyngHighlighterColors.ANNOTATION) + tokens.forEach { s -> + if (s.kind == HighlightKind.Label) { + val start = s.range.start + val end = s.range.endExclusive + if (start in 0..end && end <= text.length && start < end) { + val lexeme = try { + text.substring(start, end) + } catch (_: Throwable) { + null + } + if (lexeme != null) { + // Heuristic: if it starts with @ and follows a control keyword, it's likely a label + // Otherwise if it starts with @ it's an annotation. + // If it ends with @ it's a loop label. + when { + lexeme.endsWith("@") -> putRange(start, end, LyngHighlighterColors.LABEL) + lexeme.startsWith("@") -> { + // Try to see if it's an exit label + val prevNonWs = prevNonWs(text, start) + val prevWord = if (prevNonWs >= 0) { + var wEnd = prevNonWs + 1 + var wStart = prevNonWs + while (wStart > 0 && text[wStart - 1].isLetter()) wStart-- + text.substring(wStart, wEnd) + } else null + + if (prevWord in setOf("return", "break", "continue") || isFollowedByParenOrBlock(end)) { + putRange(start, end, LyngHighlighterColors.LABEL) + } else { + putRange(start, end, LyngHighlighterColors.ANNOTATION) + } } } } @@ -292,11 +294,13 @@ class LyngExternalAnnotator : ExternalAnnotator + if (s.kind == HighlightKind.EnumConstant) { + val start = s.range.start + val end = s.range.endExclusive + if (start in 0..end && end <= text.length && start < end) { + putRange(start, end, LyngHighlighterColors.ENUM_CONSTANT) + } } } } @@ -305,12 +309,14 @@ class LyngExternalAnnotator : ExternalAnnotator() try { val binding = Binder.bind(text, mini) - for (sym in binding.symbols) { - val s = sym.declStart; val e = sym.declEnd + binding.symbols.forEach { sym -> + val s = sym.declStart + val e = sym.declEnd if (s in 0..e && e <= text.length && s < e) idRanges += (s until e) } - for (ref in binding.references) { - val s = ref.start; val e = ref.end + binding.references.forEach { ref -> + val s = ref.start + val e = ref.end if (s in 0..e && e <= text.length && s < e) idRanges += (s until e) } } catch (_: Throwable) { @@ -329,12 +335,13 @@ class LyngExternalAnnotator : ExternalAnnotator { 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 8c7a056..0178483 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 @@ -24,13 +24,9 @@ import com.intellij.openapi.editor.Editor import com.intellij.openapi.util.TextRange import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile -import kotlinx.coroutines.runBlocking -import net.sergeych.lyng.Compiler -import net.sergeych.lyng.Pos -import net.sergeych.lyng.Source import net.sergeych.lyng.highlight.offsetOf import net.sergeych.lyng.idea.LyngLanguage -import net.sergeych.lyng.idea.util.IdeLenientImportProvider +import net.sergeych.lyng.idea.util.LyngAstManager import net.sergeych.lyng.idea.util.TextCtx import net.sergeych.lyng.miniast.* @@ -69,80 +65,90 @@ 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 partial AST. - val sink = MiniAstBuilder() - val provider = IdeLenientImportProvider.create() - val src = Source("", text) - val mini = try { - runBlocking { Compiler.compileWithMini(src, provider, sink) } - sink.build() - } catch (t: Throwable) { - 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 + // 1. Get merged mini-AST from Manager (handles local + .lyng.d merged declarations) + val mini = LyngAstManager.getMiniAst(file) ?: return null + val miniSource = mini.range.start.source // Try resolve to: function param at position, function/class/val declaration at position // 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) + mini.declarations.forEach { d -> + if (d.name == name) { + val s: Int = miniSource.offsetOf(d.nameStart) + if (s <= offset && s + 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 + d.members.forEach { m -> + if (m.name == name) { + val s: Int = miniSource.offsetOf(m.nameStart) + if (s <= offset && s + m.name.length > offset) { + return when (m) { + is MiniMemberFunDecl -> renderMemberFunDoc(d.name, m) + is MiniMemberValDecl -> renderMemberValDoc(d.name, m) + else -> 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) + d.ctorFields.forEach { cf -> + if (cf.name == name) { + val s: Int = miniSource.offsetOf(cf.nameStart) + if (s <= offset && s + 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) + d.classFields.forEach { cf -> + if (cf.name == name) { + val s: Int = miniSource.offsetOf(cf.nameStart) + if (s <= offset && s + 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}
" + if (d.entries.contains(name)) { + val s: Int = miniSource.offsetOf(d.range.start) + val e: Int = miniSource.offsetOf(d.range.end) + if (offset >= s && offset <= e) { + // For enum constant, we don't have detailed docs in MiniAst yet, but we can render a title + return "
enum constant ${d.name}.${name}
" + } } } } // 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) + mini.declarations.filterIsInstance().forEach { fn -> + fn.params.forEach { p -> + if (p.name == name) { + val s: Int = miniSource.offsetOf(p.nameStart) + if (s <= offset && s + p.name.length > offset) { + return renderParamDoc(fn, p) + } } } } @@ -156,62 +162,75 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { 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 + var dsFound: MiniDecl? = null + mini.declarations.forEach { decl -> + if (decl.name == sym.name) { + val sOffset: Int = miniSource.offsetOf(decl.nameStart) + if (sOffset == sym.declStart) { + dsFound = decl + } + } } - if (ds != null) return renderDeclDoc(ds) + if (dsFound != null) return renderDeclDoc(dsFound) // 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) + mini.declarations.filterIsInstance().forEach { fn -> + fn.params.forEach { p -> + if (p.name == sym.name) { + val sOffset: Int = miniSource.offsetOf(p.nameStart) + if (sOffset == 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 + mini.declarations.filterIsInstance().forEach { cls -> + cls.members.forEach { m -> + if (m.name == sym.name) { + val sOffset: Int = miniSource.offsetOf(m.nameStart) + if (sOffset == sym.declStart) { + return when (m) { + is MiniMemberFunDecl -> renderMemberFunDoc(cls.name, m) + is MiniMemberValDecl -> renderMemberValDoc(cls.name, m) + else -> 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) + cls.ctorFields.forEach { cf -> + if (cf.name == sym.name) { + val sOffset: Int = miniSource.offsetOf(cf.nameStart) + if (sOffset == 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) + cls.classFields.forEach { cf -> + if (cf.name == sym.name) { + val sOffset: Int = miniSource.offsetOf(cf.nameStart) + if (sOffset == 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) + } } } } @@ -260,30 +279,30 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { } else null } else -> { - val guessed = DocLookupUtils.guessClassFromCallBefore(text, dotPos, importedModules) - if (guessed != null) guessed - else { - // handle this@Type or as Type - val i2 = TextCtx.prevNonWs(text, dotPos - 1) - if (i2 >= 0) { - val identRange = TextCtx.wordRangeAt(text, i2 + 1) - if (identRange != null) { - val id = text.substring(identRange.startOffset, identRange.endOffset) - val k = TextCtx.prevNonWs(text, identRange.startOffset - 1) - if (k >= 1 && text[k] == 's' && text[k-1] == 'a' && (k-1 == 0 || !text[k-2].isLetterOrDigit())) { - id - } else if (k >= 0 && text[k] == '@') { - val k2 = TextCtx.prevNonWs(text, k - 1) - if (k2 >= 3 && text.substring(k2 - 3, k2 + 1) == "this") id else null + DocLookupUtils.guessReceiverClassViaMini(mini, text, dotPos, importedModules) + ?: DocLookupUtils.guessClassFromCallBefore(text, dotPos, importedModules, mini) + ?: run { + // handle this@Type or as Type + val i2 = TextCtx.prevNonWs(text, dotPos - 1) + if (i2 >= 0) { + val identRange = TextCtx.wordRangeAt(text, i2 + 1) + if (identRange != null) { + val id = text.substring(identRange.startOffset, identRange.endOffset) + val k = TextCtx.prevNonWs(text, identRange.startOffset - 1) + if (k >= 1 && text[k] == 's' && text[k - 1] == 'a' && (k - 1 == 0 || !text[k - 2].isLetterOrDigit())) { + id + } else if (k >= 0 && text[k] == '@') { + val k2 = TextCtx.prevNonWs(text, k - 1) + if (k2 >= 3 && text.substring(k2 - 3, k2 + 1) == "this") id else null + } else null } else null } else null - } else null - } + } } } - if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: memberCtx dotPos=${dotPos} chBeforeDot='${if (dotPos>0) text[dotPos-1] else ' '}' classGuess=${className} imports=${importedModules}") + if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: memberCtx dotPos=${dotPos} chBeforeDot='${if (dotPos > 0) text[dotPos - 1] else ' '}' classGuess=${className} imports=${importedModules}") if (className != null) { - DocLookupUtils.resolveMemberWithInheritance(importedModules, className, ident)?.let { (owner, member) -> + DocLookupUtils.resolveMemberWithInheritance(importedModules, className, ident, mini)?.let { (owner, member) -> if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] QuickDoc: literal/call '$ident' resolved to $owner.${member.name}") return when (member) { is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) @@ -339,7 +358,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { val lhs = previousWordBefore(text, idRange.startOffset) if (lhs != null && hasDotBetween(text, lhs.endOffset, idRange.startOffset)) { val className = text.substring(lhs.startOffset, lhs.endOffset) - DocLookupUtils.resolveMemberWithInheritance(importedModules, className, ident)?.let { (owner, member) -> + DocLookupUtils.resolveMemberWithInheritance(importedModules, className, ident, mini)?.let { (owner, member) -> if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] Inheritance resolved $className.$ident to $owner.${member.name}") return when (member) { is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) @@ -355,10 +374,10 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { if (dotPos != null) { val guessed = when { looksLikeListLiteralBefore(text, dotPos) -> "List" - else -> DocLookupUtils.guessClassFromCallBefore(text, dotPos, importedModules) + else -> DocLookupUtils.guessClassFromCallBefore(text, dotPos, importedModules, mini) } if (guessed != null) { - DocLookupUtils.resolveMemberWithInheritance(importedModules, guessed, ident)?.let { (owner, member) -> + DocLookupUtils.resolveMemberWithInheritance(importedModules, guessed, ident, mini)?.let { (owner, member) -> if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] Heuristic '$guessed.$ident' resolved via inheritance to $owner.${member.name}") return when (member) { is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) @@ -371,7 +390,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { run { val candidates = listOf("String", "Iterable", "Iterator", "List", "Collection", "Array", "Dict", "Regex") for (c in candidates) { - DocLookupUtils.resolveMemberWithInheritance(importedModules, c, ident)?.let { (owner, member) -> + DocLookupUtils.resolveMemberWithInheritance(importedModules, c, ident, mini)?.let { (owner, member) -> if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] Candidate '$c.$ident' resolved via inheritance to $owner.${member.name}") return when (member) { is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) @@ -383,7 +402,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { } // As a last resort try aggregated String members (extensions from stdlib text) run { - val classes = DocLookupUtils.aggregateClasses(importedModules) + val classes = DocLookupUtils.aggregateClasses(importedModules, mini) val stringCls = classes["String"] val m = stringCls?.members?.firstOrNull { it.name == ident } if (m != null) { @@ -396,7 +415,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { } } // Search across classes; prefer Iterable, then Iterator, then List for common ops - DocLookupUtils.findMemberAcrossClasses(importedModules, ident)?.let { (owner, member) -> + DocLookupUtils.findMemberAcrossClasses(importedModules, ident, mini)?.let { (owner, member) -> if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] Cross-class '$ident' resolved to $owner.${member.name}") return when (member) { is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) @@ -609,10 +628,14 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { } private fun previousWordBefore(text: String, offset: Int): TextRange? { - // skip spaces and dots to the left, but stop after hitting a non-identifier or dot boundary + // skip spaces and the dot to the left, but stop after hitting a non-identifier boundary var i = (offset - 1).coerceAtLeast(0) - // first, move left past spaces - while (i > 0 && text[i].isWhitespace()) i-- + // skip trailing spaces + while (i >= 0 && text[i].isWhitespace()) i-- + // skip the dot if present + if (i >= 0 && text[i] == '.') i-- + // skip spaces before the dot + while (i >= 0 && text[i].isWhitespace()) i-- // remember position to check for dot between words val end = i + 1 // now find the start of the identifier 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 index a965aa2..a93b554 100644 --- 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 @@ -17,6 +17,7 @@ package net.sergeych.lyng.idea.util +import com.intellij.openapi.application.runReadAction import com.intellij.openapi.util.Key import com.intellij.psi.PsiFile import com.intellij.psi.PsiManager @@ -33,14 +34,15 @@ object LyngAstManager { 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 + fun getMiniAst(file: PsiFile): MiniScript? = runReadAction { + val vFile = file.virtualFile ?: return@runReadAction null + val combinedStamp = getCombinedStamp(file) + val prevStamp = file.getUserData(STAMP_KEY) val cached = file.getUserData(MINI_KEY) - if (cached != null && prevStamp != null && prevStamp == stamp) return cached + if (cached != null && prevStamp != null && prevStamp == combinedStamp) return@runReadAction cached - val text = doc.text + val text = file.viewProvider.contents.toString() val sink = MiniAstBuilder() val built = try { val provider = IdeLenientImportProvider.create() @@ -48,7 +50,14 @@ object LyngAstManager { runBlocking { Compiler.compileWithMini(src, provider, sink) } val script = sink.build() if (script != null && !file.name.endsWith(".lyng.d")) { - mergeDeclarationFiles(file, script) + val dFiles = collectDeclarationFiles(file) + for (df in dFiles) { + val scriptD = getMiniAst(df) + if (scriptD != null) { + script.declarations.addAll(scriptD.declarations) + script.imports.addAll(scriptD.imports) + } + } } script } catch (_: Throwable) { @@ -57,53 +66,68 @@ object LyngAstManager { if (built != null) { file.putUserData(MINI_KEY, built) - file.putUserData(STAMP_KEY, stamp) + file.putUserData(STAMP_KEY, combinedStamp) // Invalidate binding too file.putUserData(BINDING_KEY, null) } - return built + built } - private fun mergeDeclarationFiles(file: PsiFile, mainScript: MiniScript) { + fun getCombinedStamp(file: PsiFile): Long = runReadAction { + var combinedStamp = file.viewProvider.modificationStamp + if (!file.name.endsWith(".lyng.d")) { + collectDeclarationFiles(file).forEach { df -> + combinedStamp += df.viewProvider.modificationStamp + } + } + combinedStamp + } + + private fun collectDeclarationFiles(file: PsiFile): List = runReadAction { val psiManager = PsiManager.getInstance(file.project) var current = file.virtualFile?.parent val seen = mutableSetOf() + val result = mutableListOf() while (current != null) { for (child in current.children) { if (child.name.endsWith(".lyng.d") && child != file.virtualFile && seen.add(child.path)) { val psiD = psiManager.findFile(child) ?: continue - val scriptD = getMiniAst(psiD) - if (scriptD != null) { - mainScript.declarations.addAll(scriptD.declarations) - mainScript.imports.addAll(scriptD.imports) - } + result.add(psiD) } } current = current.parent } + result } - fun getBinding(file: PsiFile): BindingSnapshot? { - val doc = file.viewProvider.document ?: return null - val stamp = doc.modificationStamp + fun getBinding(file: PsiFile): BindingSnapshot? = runReadAction { + val vFile = file.virtualFile ?: return@runReadAction null + var combinedStamp = file.viewProvider.modificationStamp + + val dFiles = if (!file.name.endsWith(".lyng.d")) collectDeclarationFiles(file) else emptyList() + for (df in dFiles) { + combinedStamp += df.viewProvider.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 + + if (cached != null && prevStamp != null && prevStamp == combinedStamp) return@runReadAction cached + + val mini = getMiniAst(file) ?: return@runReadAction null + val text = file.viewProvider.contents.toString() val binding = try { Binder.bind(text, mini) } catch (_: Throwable) { null } - + if (binding != null) { file.putUserData(BINDING_KEY, binding) - // stamp is already set by getMiniAst + // stamp is already set by getMiniAst or we set it here if getMiniAst was cached + file.putUserData(STAMP_KEY, combinedStamp) } - return binding + binding } } diff --git a/lyng-idea/src/main/resources/META-INF/plugin.xml b/lyng-idea/src/main/resources/META-INF/plugin.xml index 42ffa0d..f682710 100644 --- a/lyng-idea/src/main/resources/META-INF/plugin.xml +++ b/lyng-idea/src/main/resources/META-INF/plugin.xml @@ -19,7 +19,7 @@ net.sergeych.lyng.idea - Lyng Language Support + Lyng Sergey Chernov @@ -42,7 +42,8 @@ - + + diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index a96d70b..dbec7f2 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -2936,7 +2936,9 @@ class Compiler( returnType = returnTypeMini, body = bodyRange?.let { MiniBlock(it) }, doc = declDocLocal, - nameStart = nameStartPos + nameStart = nameStartPos, + receiver = receiverMini, + isExtern = actualExtern ) miniSink?.onFunDecl(node) } 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 47f8b22..23dabab 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt @@ -58,11 +58,11 @@ object CompletionEngineLight { return completeSuspend(text, idx) } - suspend fun completeSuspend(text: String, caret: Int): List { + suspend fun completeSuspend(text: String, caret: Int, providedMini: MiniScript? = null): List { // Ensure stdlib Obj*-defined docs (e.g., String methods) are initialized before registry lookup StdlibDocsBootstrap.ensure() val prefix = prefixAt(text, caret) - val mini = buildMiniAst(text) + val mini = providedMini ?: buildMiniAst(text) val imported: List = DocLookupUtils.canonicalImportedModules(mini ?: return emptyList(), text) val cap = 200 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 3b11992..23d10a7 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt @@ -430,6 +430,7 @@ object DocLookupUtils { fun scanLocalClassMembersFromText(mini: MiniScript, text: String, cls: MiniClassDecl): Map { val src = mini.range.start.source + if (cls.nameStart.source != src) return emptyMap() 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() diff --git a/lynglib/src/commonTest/kotlin/MiniAstTest.kt b/lynglib/src/commonTest/kotlin/MiniAstTest.kt index 4371f22..b1462d1 100644 --- a/lynglib/src/commonTest/kotlin/MiniAstTest.kt +++ b/lynglib/src/commonTest/kotlin/MiniAstTest.kt @@ -273,4 +273,42 @@ class MiniAstTest { assertNotNull(e1) assertEquals("Doc6", e1.doc?.summary) } + + @Test + fun miniAst_captures_user_sample_extern_doc() = runTest { + val code = """ + /* + the plugin testing .d sample + */ + extern fun test(value: Int): String + """.trimIndent() + val (_, sink) = compileWithMini(code) + val mini = sink.build() + assertNotNull(mini) + val test = mini.declarations.filterIsInstance().firstOrNull { it.name == "test" } + assertNotNull(test, "function 'test' should be captured") + assertNotNull(test.doc, "doc for 'test' should be captured") + assertEquals("the plugin testing .d sample", test.doc.summary) + assertTrue(test.isExtern, "function 'test' should be extern") + } + + @Test + fun resolve_object_member_doc() = runTest { + val code = """ + object O3 { + /* doc for name */ + fun name() = "ozone" + } + """.trimIndent() + val (_, sink) = compileWithMini(code) + val mini = sink.build() + assertNotNull(mini) + + val imported = listOf("lyng.stdlib") + // Simulate looking up O3.name + val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, "O3", "name", mini) + assertNotNull(resolved) + assertEquals("O3", resolved.first) + assertEquals("doc for name", resolved.second.doc?.summary) + } }