From c16c0d7ebdfa5a2ce785e8bd642ba4cf655d7ca3 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sat, 7 Feb 2026 05:47:18 +0300 Subject: [PATCH] language tools and site suport --- .../idea/annotators/LyngExternalAnnotator.kt | 292 ++--------- .../completion/LyngCompletionContributor.kt | 9 +- .../idea/docs/LyngDocumentationProvider.kt | 62 ++- .../lyng/idea/navigation/LyngPsiReference.kt | 11 +- .../sergeych/lyng/idea/util/LyngAstManager.kt | 99 ++-- .../net/sergeych/lyng/binding/Binder.kt | 31 +- .../lyng/miniast/CompletionEngineLight.kt | 16 +- .../sergeych/lyng/miniast/DocLookupUtils.kt | 15 +- .../net/sergeych/lyng/miniast/MiniAst.kt | 58 +++ .../sergeych/lyng/tools/LyngLanguageTools.kt | 467 ++++++++++++++++++ lynglib/src/commonTest/kotlin/StdlibTest.kt | 12 + .../lyng/tools/LyngLanguageToolsTest.kt | 113 +++++ .../kotlin/net/sergeych/lyngweb/Editor.kt | 305 +++++++++++- .../net/sergeych/lyngweb/LyngWebTools.kt | 55 +++ site/src/jsMain/kotlin/ReferencePage.kt | 12 + site/src/jsMain/kotlin/TryLyngPage.kt | 183 ++++++- 16 files changed, 1402 insertions(+), 338 deletions(-) create mode 100644 lynglib/src/commonMain/kotlin/net/sergeych/lyng/tools/LyngLanguageTools.kt create mode 100644 lynglib/src/commonTest/kotlin/net/sergeych/lyng/tools/LyngLanguageToolsTest.kt create mode 100644 lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/LyngWebTools.kt 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 bb2a1ab..c8288ae 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,15 +25,13 @@ import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.util.Key import com.intellij.openapi.util.TextRange import com.intellij.psi.PsiFile -import net.sergeych.lyng.Source -import net.sergeych.lyng.binding.Binder -import net.sergeych.lyng.binding.SymbolKind 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.LyngAstManager -import net.sergeych.lyng.miniast.* +import net.sergeych.lyng.tools.LyngDiagnosticSeverity +import net.sergeych.lyng.tools.LyngLanguageTools +import net.sergeych.lyng.tools.LyngSemanticKind /** * ExternalAnnotator that runs Lyng MiniAst on the document text in background @@ -43,8 +41,8 @@ class LyngExternalAnnotator : ExternalAnnotator?, 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) - data class Result(val modStamp: Long, val spans: List, val error: Error? = null) + data class Diag(val start: Int, val end: Int, val message: String, val severity: HighlightSeverity) + data class Result(val modStamp: Long, val spans: List, val diagnostics: List = emptyList()) override fun collectInformation(file: PsiFile): Input? { val doc: Document = file.viewProvider.document ?: return null @@ -59,224 +57,46 @@ class LyngExternalAnnotator : ExternalAnnotator(256) + val mini = analysis.mini - fun isFollowedByParenOrBlock(rangeEnd: Int): Boolean { - var i = rangeEnd - while (i < text.length) { - val ch = text[i] - if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') { i++; continue } - return ch == '(' || ch == '{' - } - return false - } + ProgressManager.checkCanceled() + + val out = ArrayList(256) + val diags = ArrayList() fun putRange(start: Int, end: Int, key: com.intellij.openapi.editor.colors.TextAttributesKey) { if (start in 0..end && end <= text.length && start < end) out += Span(start, end, key) } - fun putName(startPos: net.sergeych.lyng.Pos, name: String, key: com.intellij.openapi.editor.colors.TextAttributesKey) { - val s = source.offsetOf(startPos) - putRange(s, (s + name.length).coerceAtMost(text.length), key) - } - fun putMiniRange(r: MiniRange, key: com.intellij.openapi.editor.colors.TextAttributesKey) { - val s = source.offsetOf(r.start) - val e = source.offsetOf(r.end) - putRange(s, e, key) + + fun keyForKind(kind: LyngSemanticKind): com.intellij.openapi.editor.colors.TextAttributesKey? = when (kind) { + LyngSemanticKind.Function -> LyngHighlighterColors.FUNCTION + LyngSemanticKind.Class, LyngSemanticKind.Enum, LyngSemanticKind.TypeAlias -> LyngHighlighterColors.TYPE + LyngSemanticKind.Value -> LyngHighlighterColors.VALUE + LyngSemanticKind.Variable -> LyngHighlighterColors.VARIABLE + LyngSemanticKind.Parameter -> LyngHighlighterColors.PARAMETER + LyngSemanticKind.TypeRef -> LyngHighlighterColors.TYPE + LyngSemanticKind.EnumConstant -> LyngHighlighterColors.ENUM_CONSTANT } - // Declarations - mini.declarations.forEach { d -> - 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) - is MiniValDecl -> putName( - d.nameStart, - d.name, - if (d.mutable) LyngHighlighterColors.VARIABLE else LyngHighlighterColors.VALUE - ) - is MiniEnumDecl -> putName(d.nameStart, d.name, LyngHighlighterColors.TYPE) - } + // Semantic highlights from shared tooling + LyngLanguageTools.semanticHighlights(analysis).forEach { span -> + keyForKind(span.kind)?.let { putRange(span.range.start, span.range.endExclusive, it) } } // Imports: each segment as namespace/path - mini.imports.forEach { imp -> - if (imp.range.start.source != source) return@forEach - imp.segments.forEach { seg -> putMiniRange(seg.range, LyngHighlighterColors.NAMESPACE) } - } - - // Parameters - fun addParams(params: List) { - params.forEach { p -> - if (p.nameStart.source == source) - putName(p.nameStart, p.name, LyngHighlighterColors.PARAMETER) + mini?.imports?.forEach { imp -> + imp.segments.forEach { seg -> + val start = analysis.source.offsetOf(seg.range.start) + val end = analysis.source.offsetOf(seg.range.end) + putRange(start, end, LyngHighlighterColors.NAMESPACE) } } - mini.declarations.forEach { d -> - when (d) { - is MiniFunDecl -> addParams(d.params) - is MiniClassDecl -> d.members.filterIsInstance().forEach { addParams(it.params) } - else -> {} - } - } - - // 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) - } - is MiniGenericType -> { - addTypeSegments(t.base) - t.args.forEach { addTypeSegments(it) } - } - is MiniFunctionType -> { - t.receiver?.let { addTypeSegments(it) } - t.params.forEach { addTypeSegments(it) } - addTypeSegments(t.returnType) - } - is MiniTypeVar -> { /* name is in range; could be highlighted as TYPE as well */ - if (t.range.start.source == source) - putMiniRange(t.range, LyngHighlighterColors.TYPE) - } - null -> {} - } - } - fun addDeclTypeSegments(d: MiniDecl) { - if (d.nameStart.source != source) return - when (d) { - is MiniFunDecl -> { - addTypeSegments(d.returnType) - d.params.forEach { addTypeSegments(it.type) } - addTypeSegments(d.receiver) - } - is MiniValDecl -> { - addTypeSegments(d.type) - addTypeSegments(d.receiver) - } - is MiniClassDecl -> { - d.ctorFields.forEach { addTypeSegments(it.type) } - d.classFields.forEach { addTypeSegments(it.type) } - for (m in d.members) { - when (m) { - is MiniMemberFunDecl -> { - addTypeSegments(m.returnType) - m.params.forEach { addTypeSegments(it.type) } - } - is MiniMemberValDecl -> { - addTypeSegments(m.type) - } - else -> {} - } - } - } - is MiniEnumDecl -> {} - } - } - mini.declarations.forEach { d -> addDeclTypeSegments(d) } - - ProgressManager.checkCanceled() - - // Semantic usages via Binder (best-effort) - try { - val binding = Binder.bind(text, mini) - - // Map declaration ranges to avoid duplicating them as usages - val declKeys = HashSet>(binding.symbols.size * 2) - binding.symbols.forEach { sym -> declKeys += (sym.declStart to sym.declEnd) } - - fun keyForKind(k: SymbolKind) = when (k) { - SymbolKind.Function -> LyngHighlighterColors.FUNCTION - SymbolKind.Class, SymbolKind.Enum -> LyngHighlighterColors.TYPE - SymbolKind.Parameter -> LyngHighlighterColors.PARAMETER - SymbolKind.Value -> LyngHighlighterColors.VALUE - SymbolKind.Variable -> LyngHighlighterColors.VARIABLE - } - - // Track covered ranges to not override later heuristics - val covered = HashSet>() - - binding.references.forEach { ref -> - val key = ref.start to ref.end - 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 - ProgressManager.checkCanceled() - - // Build simple name -> role map for top-level vals/vars and parameters - val nameRole = HashMap(8) - 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 } - is MiniClassDecl -> { - d.members.forEach { m -> - if (m is MiniMemberFunDecl) { - m.params.forEach { p -> nameRole[p.name] = LyngHighlighterColors.PARAMETER } - } - } - } - else -> {} - } - } - - 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 - } - } - } - } - } - } - } catch (e: Throwable) { - // Must rethrow cancellation; otherwise ignore binder failures (best-effort) - if (e is com.intellij.openapi.progress.ProcessCanceledException) throw e - } // Add annotation/label coloring using token highlighter run { - tokens.forEach { s -> + analysis.lexicalHighlights.forEach { s -> if (s.kind == HighlightKind.Label) { val start = s.range.start val end = s.range.endExclusive @@ -302,7 +122,7 @@ 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) - } - } + analysis.diagnostics.forEach { d -> + val range = d.range ?: return@forEach + val severity = if (d.severity == LyngDiagnosticSeverity.Warning) HighlightSeverity.WARNING else HighlightSeverity.ERROR + diags += Diag(range.start, range.endExclusive, d.message, severity) } - return Result(collectedInfo.modStamp, out, null) + return Result(collectedInfo.modStamp, out, diags) } @@ -346,13 +162,12 @@ class LyngExternalAnnotator : ExternalAnnotator + val start = d.start.coerceIn(0, (doc?.textLength ?: 0)) + val end = d.end.coerceIn(start, (doc?.textLength ?: start)) if (end > start) { - holder.newAnnotation(HighlightSeverity.ERROR, err.message) + holder.newAnnotation(d.severity, d.message) .range(TextRange(start, end)) .create() } @@ -373,30 +188,5 @@ class LyngExternalAnnotator : ExternalAnnotator { - if (text.isEmpty()) return 0 to 0 - val len = text.length - val start = rawStart.coerceIn(0, len) - fun isWord(ch: Char) = ch == '_' || ch.isLetterOrDigit() - - if (start < len && isWord(text[start])) { - var s = start - var e = start - while (s > 0 && isWord(text[s - 1])) s-- - while (e < len && isWord(text[e])) e++ - return s to e - } - - // Not inside a word: select a short, visible range up to EOL - val lineEnd = text.indexOf('\n', start).let { if (it == -1) len else it } - val minWidth = 4 - val end = (start + minWidth).coerceAtMost(lineEnd).coerceAtLeast((start + 1).coerceAtMost(lineEnd)) - return start to end - } + } 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 62925ac..f61ddb7 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 @@ -96,9 +96,10 @@ class LyngCompletionContributor : CompletionContributor() { log.info("[LYNG_DEBUG] Completion: caret=$caret prefix='${prefix}' memberDotPos=${memberDotPos} file='${file.name}'") } - // Build MiniAst (cached) for both global and member contexts to enable local class/val inference - val mini = LyngAstManager.getMiniAst(file) - val binding = LyngAstManager.getBinding(file) + // Build analysis (cached) for both global and member contexts to enable local class/val inference + val analysis = LyngAstManager.getAnalysis(file) + val mini = analysis?.mini + val binding = analysis?.binding // Delegate computation to the shared engine to keep behavior in sync with tests val engineItems = try { @@ -160,6 +161,8 @@ class LyngCompletionContributor : CompletionContributor() { .withIcon(AllIcons.Nodes.Class) Kind.Enum -> LookupElementBuilder.create(ci.name) .withIcon(AllIcons.Nodes.Enum) + Kind.TypeAlias -> LookupElementBuilder.create(ci.name) + .withIcon(AllIcons.Nodes.Class) Kind.Value -> LookupElementBuilder.create(ci.name) .withIcon(AllIcons.Nodes.Variable) .let { b -> if (!ci.typeText.isNullOrBlank()) b.withTypeText(ci.typeText, true) else b } 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 82e4cef..361bcfd 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 @@ -29,6 +29,8 @@ import net.sergeych.lyng.idea.LyngLanguage import net.sergeych.lyng.idea.util.LyngAstManager import net.sergeych.lyng.idea.util.TextCtx import net.sergeych.lyng.miniast.* +import net.sergeych.lyng.tools.LyngLanguageTools +import net.sergeych.lyng.tools.LyngSymbolInfo /** * Quick Docs backed by MiniAst: when caret is on an identifier that corresponds @@ -66,9 +68,15 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: ident='$ident' at ${idRange.startOffset}..${idRange.endOffset} in ${file.name}") // 1. Get merged mini-AST from Manager (handles local + .lyng.d merged declarations) - val mini = LyngAstManager.getMiniAst(file) ?: return null + val analysis = LyngAstManager.getAnalysis(file) ?: return null + val mini = analysis.mini ?: return null val miniSource = mini.range.start.source - val imported = DocLookupUtils.canonicalImportedModules(mini, text) + val imported = analysis.importedModules.ifEmpty { DocLookupUtils.canonicalImportedModules(mini, text) } + + // Single-source quick doc lookup + LyngLanguageTools.docAt(analysis, offset)?.let { info -> + renderDocFromInfo(info)?.let { return it } + } // Try resolve to: function param at position, function/class/val declaration at position // 1) Use unified declaration detection @@ -91,6 +99,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { return when (m) { is MiniMemberFunDecl -> renderMemberFunDoc(d.name, m) is MiniMemberValDecl -> renderMemberValDoc(d.name, m) + is MiniMemberTypeAliasDecl -> renderMemberTypeAliasDoc(d.name, m) else -> null } } @@ -197,6 +206,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { return when (m) { is MiniMemberFunDecl -> renderMemberFunDoc(cls.name, m) is MiniMemberValDecl -> renderMemberValDoc(cls.name, m) + is MiniMemberTypeAliasDecl -> renderMemberTypeAliasDoc(cls.name, m) else -> null } } @@ -312,11 +322,13 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { return when (member) { is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) is MiniMemberValDecl -> renderMemberValDoc(owner, member) + is MiniMemberTypeAliasDecl -> renderMemberTypeAliasDoc(owner, member) is MiniInitDecl -> null is MiniFunDecl -> renderDeclDoc(member, text, mini, importedModules) is MiniValDecl -> renderDeclDoc(member, text, mini, importedModules) is MiniClassDecl -> renderDeclDoc(member, text, mini, importedModules) is MiniEnumDecl -> renderDeclDoc(member, text, mini, importedModules) + is MiniTypeAliasDecl -> renderDeclDoc(member, text, mini, importedModules) } } log.info("[LYNG_DEBUG] QuickDoc: resolve failed for ${className}.${ident}") @@ -354,6 +366,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { // And classes/enums docs.filterIsInstance().firstOrNull { it.name == ident }?.let { return renderDeclDoc(it, text, mini, imported) } docs.filterIsInstance().firstOrNull { it.name == ident }?.let { return renderDeclDoc(it, text, mini, imported) } + docs.filterIsInstance().firstOrNull { it.name == ident }?.let { return renderDeclDoc(it, text, mini, imported) } } // Defensive fallback: if nothing found and it's a well-known stdlib function, render minimal inline docs if (ident == "println" || ident == "print") { @@ -372,11 +385,13 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { return when (member) { is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) is MiniMemberValDecl -> renderMemberValDoc(owner, member) + is MiniMemberTypeAliasDecl -> renderMemberTypeAliasDoc(owner, member) is MiniInitDecl -> null is MiniFunDecl -> renderDeclDoc(member, text, mini, importedModules) is MiniValDecl -> renderDeclDoc(member, text, mini, importedModules) is MiniClassDecl -> renderDeclDoc(member, text, mini, importedModules) is MiniEnumDecl -> renderDeclDoc(member, text, mini, importedModules) + is MiniTypeAliasDecl -> renderDeclDoc(member, text, mini, importedModules) } } } else { @@ -395,11 +410,13 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { return when (member) { is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) is MiniMemberValDecl -> renderMemberValDoc(owner, member) + is MiniMemberTypeAliasDecl -> renderMemberTypeAliasDoc(owner, member) is MiniInitDecl -> null is MiniFunDecl -> renderDeclDoc(member, text, mini, importedModules) is MiniValDecl -> renderDeclDoc(member, text, mini, importedModules) is MiniClassDecl -> renderDeclDoc(member, text, mini, importedModules) is MiniEnumDecl -> renderDeclDoc(member, text, mini, importedModules) + is MiniTypeAliasDecl -> renderDeclDoc(member, text, mini, importedModules) } } } else { @@ -412,11 +429,13 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { return when (member) { is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) is MiniMemberValDecl -> renderMemberValDoc(owner, member) + is MiniMemberTypeAliasDecl -> renderMemberTypeAliasDoc(owner, member) is MiniInitDecl -> null is MiniFunDecl -> renderDeclDoc(member, text, mini, importedModules) is MiniValDecl -> renderDeclDoc(member, text, mini, importedModules) is MiniClassDecl -> renderDeclDoc(member, text, mini, importedModules) is MiniEnumDecl -> renderDeclDoc(member, text, mini, importedModules) + is MiniTypeAliasDecl -> renderDeclDoc(member, text, mini, importedModules) } } } @@ -431,6 +450,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { return when (m) { is MiniMemberFunDecl -> renderMemberFunDoc("String", m) is MiniMemberValDecl -> renderMemberValDoc("String", m) + is MiniMemberTypeAliasDecl -> renderMemberTypeAliasDoc("String", m) is MiniInitDecl -> null } } @@ -512,6 +532,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { is MiniFunDecl -> "function ${d.name}${signatureOf(d)}" is MiniClassDecl -> "class ${d.name}" is MiniEnumDecl -> "enum ${d.name} { ${d.entries.joinToString(", ")} }" + is MiniTypeAliasDecl -> "type ${d.name}${typeAliasSuffix(d)}" is MiniValDecl -> { val t = d.type ?: DocLookupUtils.inferTypeRefForVal(d, text, imported, mini) val typeStr = if (t == null) ": Object?" else typeOf(t) @@ -524,6 +545,24 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { return sb.toString() } + private fun renderDocFromInfo(info: LyngSymbolInfo): String? { + val kind = when (info.target.kind) { + net.sergeych.lyng.binding.SymbolKind.Function -> "function" + net.sergeych.lyng.binding.SymbolKind.Class -> "class" + net.sergeych.lyng.binding.SymbolKind.Enum -> "enum" + net.sergeych.lyng.binding.SymbolKind.TypeAlias -> "type" + net.sergeych.lyng.binding.SymbolKind.Value -> "val" + net.sergeych.lyng.binding.SymbolKind.Variable -> "var" + net.sergeych.lyng.binding.SymbolKind.Parameter -> "parameter" + } + val title = info.signature ?: "$kind ${info.target.name}" + if (title.isBlank() && info.doc == null) return null + val sb = StringBuilder() + sb.append(renderTitle(title)) + sb.append(renderDocBody(info.doc)) + return sb.toString() + } + private fun renderParamDoc(fn: MiniFunDecl, p: MiniParam): String { val title = "parameter ${p.name}${typeOf(p.type)} in ${fn.name}${signatureOf(fn)}" val sb = StringBuilder() @@ -565,6 +604,25 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { return sb.toString() } + private fun renderMemberTypeAliasDoc(className: String, m: MiniMemberTypeAliasDecl): String { + val tp = if (m.typeParams.isEmpty()) "" else "<" + m.typeParams.joinToString(", ") + ">" + val body = typeOf(m.target) + val rhs = if (body.isBlank()) "" else " = ${body.removePrefix(": ")}" + val staticStr = if (m.isStatic) "static " else "" + val title = "${staticStr}type $className.${m.name}$tp$rhs" + val sb = StringBuilder() + sb.append(renderTitle(title)) + sb.append(renderDocBody(m.doc)) + return sb.toString() + } + + private fun typeAliasSuffix(d: MiniTypeAliasDecl): String { + val tp = if (d.typeParams.isEmpty()) "" else "<" + d.typeParams.joinToString(", ") + ">" + val body = typeOf(d.target) + val rhs = if (body.isBlank()) "" else " = ${body.removePrefix(": ")}" + return "$tp$rhs" + } + private fun typeOf(t: MiniTypeRef?): String { val s = DocLookupUtils.typeOf(t) return if (s.isEmpty()) (if (t == null) ": Object?" else "") else ": $s" 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 index 95ea04e..5f4733d 100644 --- 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 @@ -36,9 +36,10 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase() - val mini = LyngAstManager.getMiniAst(file) ?: return emptyArray() - val binding = LyngAstManager.getBinding(file) - val imported = DocLookupUtils.canonicalImportedModules(mini, text).toSet() + val analysis = LyngAstManager.getAnalysis(file) ?: return emptyArray() + val mini = analysis.mini ?: return emptyArray() + val binding = analysis.binding + val imported = analysis.importedModules.toSet() val currentPackage = getPackageName(file) val allowedPackages = if (currentPackage != null) imported + currentPackage else imported @@ -64,11 +65,13 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase "Function" is MiniMemberValDecl -> if (member.mutable) "Variable" else "Value" + is MiniMemberTypeAliasDecl -> "TypeAlias" is MiniInitDecl -> "Initializer" is MiniFunDecl -> "Function" is MiniValDecl -> if (member.mutable) "Variable" else "Value" is MiniClassDecl -> "Class" is MiniEnumDecl -> "Enum" + is MiniTypeAliasDecl -> "TypeAlias" } results.add(PsiElementResolveResult(LyngDeclarationElement(it, member.name, kind))) } @@ -199,6 +202,7 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase "Class" is net.sergeych.lyng.miniast.MiniEnumDecl -> "Enum" is net.sergeych.lyng.miniast.MiniValDecl -> if (d.mutable) "Variable" else "Value" + is net.sergeych.lyng.miniast.MiniTypeAliasDecl -> "TypeAlias" } addIfMatch(d.name, d.nameStart, dKind) } @@ -214,6 +218,7 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase "Function" is net.sergeych.lyng.miniast.MiniMemberValDecl -> if (m.mutable) "Variable" else "Value" + is net.sergeych.lyng.miniast.MiniMemberTypeAliasDecl -> "TypeAlias" is net.sergeych.lyng.miniast.MiniInitDecl -> "Initializer" } addIfMatch(m.name, m.nameStart, mKind) 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 a93b554..d0a9654 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 @@ -22,55 +22,21 @@ import com.intellij.openapi.util.Key import com.intellij.psi.PsiFile import com.intellij.psi.PsiManager 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.DocLookupUtils import net.sergeych.lyng.miniast.MiniScript +import net.sergeych.lyng.tools.LyngAnalysisRequest +import net.sergeych.lyng.tools.LyngAnalysisResult +import net.sergeych.lyng.tools.LyngLanguageTools 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") + private val ANALYSIS_KEY = Key.create("lyng.analysis.cache") 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 == combinedStamp) return@runReadAction cached - - val text = file.viewProvider.contents.toString() - val sink = MiniAstBuilder() - val built = try { - val provider = IdeLenientImportProvider.create() - val src = Source(file.name, text) - runBlocking { Compiler.compileWithMini(src, provider, sink) } - val script = sink.build() - if (script != null && !file.name.endsWith(".lyng.d")) { - 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) { - sink.build() - } - - if (built != null) { - file.putUserData(MINI_KEY, built) - file.putUserData(STAMP_KEY, combinedStamp) - // Invalidate binding too - file.putUserData(BINDING_KEY, null) - } - built + getAnalysis(file)?.mini } fun getCombinedStamp(file: PsiFile): Long = runReadAction { @@ -102,32 +68,53 @@ object LyngAstManager { } fun getBinding(file: PsiFile): BindingSnapshot? = runReadAction { + getAnalysis(file)?.binding + } + + fun getAnalysis(file: PsiFile): LyngAnalysisResult? = 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 combinedStamp = getCombinedStamp(file) val prevStamp = file.getUserData(STAMP_KEY) - val cached = file.getUserData(BINDING_KEY) - + val cached = file.getUserData(ANALYSIS_KEY) 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) + val built = try { + val provider = IdeLenientImportProvider.create() + runBlocking { + LyngLanguageTools.analyze( + LyngAnalysisRequest(text = text, fileName = file.name, importProvider = provider) + ) + } } catch (_: Throwable) { null } - if (binding != null) { - file.putUserData(BINDING_KEY, binding) - // stamp is already set by getMiniAst or we set it here if getMiniAst was cached + if (built != null) { + val merged = built.mini + if (merged != null && !file.name.endsWith(".lyng.d")) { + val dFiles = collectDeclarationFiles(file) + for (df in dFiles) { + val dAnalysis = getAnalysis(df) + val dMini = dAnalysis?.mini ?: continue + merged.declarations.addAll(dMini.declarations) + merged.imports.addAll(dMini.imports) + } + } + val finalAnalysis = if (merged != null) { + built.copy( + mini = merged, + importedModules = DocLookupUtils.canonicalImportedModules(merged, text) + ) + } else { + built + } + file.putUserData(ANALYSIS_KEY, finalAnalysis) + file.putUserData(MINI_KEY, finalAnalysis.mini) + file.putUserData(BINDING_KEY, finalAnalysis.binding) file.putUserData(STAMP_KEY, combinedStamp) + return@runReadAction finalAnalysis } - binding + null } } 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 cad8679..da8b7c2 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, Value, Variable, Parameter } +enum class SymbolKind { Class, Enum, TypeAlias, Function, Value, Variable, Parameter } data class Symbol( val id: Int, @@ -126,13 +126,22 @@ object Binder { } // Members (including fields and methods) for (m in d.members) { - if (m is MiniMemberValDecl) { - val fs = source.offsetOf(m.nameStart) - val fe = fs + m.name.length - val kind = if (m.mutable) SymbolKind.Variable else SymbolKind.Value - val fieldSym = Symbol(nextId++, m.name, kind, fs, fe, containerId = sym.id, type = DocLookupUtils.typeOf(m.type)) - symbols += fieldSym - classScope.fields += fieldSym.id + when (m) { + is MiniMemberValDecl -> { + val fs = source.offsetOf(m.nameStart) + val fe = fs + m.name.length + val kind = if (m.mutable) SymbolKind.Variable else SymbolKind.Value + val fieldSym = Symbol(nextId++, m.name, kind, fs, fe, containerId = sym.id, type = DocLookupUtils.typeOf(m.type)) + symbols += fieldSym + classScope.fields += fieldSym.id + } + is MiniMemberTypeAliasDecl -> { + val fs = source.offsetOf(m.nameStart) + val fe = fs + m.name.length + val aliasSym = Symbol(nextId++, m.name, SymbolKind.TypeAlias, fs, fe, containerId = sym.id, type = DocLookupUtils.typeOf(m.target)) + symbols += aliasSym + } + else -> {} } } } @@ -197,6 +206,12 @@ object Binder { symbols += sym topLevelByName.getOrPut(d.name) { mutableListOf() }.add(sym.id) } + is MiniTypeAliasDecl -> { + val (s, e) = nameOffsets(d.nameStart, d.name) + val sym = Symbol(nextId++, d.name, SymbolKind.TypeAlias, s, e, containerId = null, type = DocLookupUtils.typeOf(d.target)) + symbols += sym + topLevelByName.getOrPut(d.name) { mutableListOf() }.add(sym.id) + } } } 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 91a7c9d..df8c757 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt @@ -36,7 +36,7 @@ data class CompletionItem( val priority: Double = 0.0, ) -enum class Kind { Function, Class_, Enum, Value, Method, Field } +enum class Kind { Function, Class_, Enum, TypeAlias, Value, Method, Field } /** * Platform-free, lenient import provider that never fails on unknown packages. @@ -118,9 +118,11 @@ object CompletionEngineLight { val classes = decls.filterIsInstance().sortedBy { it.name.lowercase() } val enums = decls.filterIsInstance().sortedBy { it.name.lowercase() } val vals = decls.filterIsInstance().sortedBy { it.name.lowercase() } + val aliases = decls.filterIsInstance().sortedBy { it.name.lowercase() } funs.forEach { offerDeclAdd(out, prefix, it) } classes.forEach { offerDeclAdd(out, prefix, it) } enums.forEach { offerDeclAdd(out, prefix, it) } + aliases.forEach { offerDeclAdd(out, prefix, it) } vals.forEach { offerDeclAdd(out, prefix, it) } // Imported and builtin @@ -135,9 +137,11 @@ object CompletionEngineLight { val classes = decls.filterIsInstance().sortedBy { it.name.lowercase() } val enums = decls.filterIsInstance().sortedBy { it.name.lowercase() } val vals = decls.filterIsInstance().sortedBy { it.name.lowercase() } + val aliases = decls.filterIsInstance().sortedBy { it.name.lowercase() } funs.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } } classes.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } } enums.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } } + aliases.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } } vals.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } } if (out.size >= cap || externalAdded >= budget) break } @@ -196,6 +200,9 @@ object CompletionEngineLight { is MiniMemberValDecl -> { add(CompletionItem(m.name, if (m.mutable) Kind.Value else Kind.Field, typeText = typeOf(m.type), priority = 100.0)) } + is MiniMemberTypeAliasDecl -> { + add(CompletionItem(m.name, Kind.TypeAlias, typeText = typeOf(m.target), priority = 100.0)) + } is MiniInitDecl -> {} } } @@ -225,6 +232,7 @@ object CompletionEngineLight { } is MiniClassDecl -> add(CompletionItem(d.name, Kind.Class_)) is MiniEnumDecl -> add(CompletionItem(d.name, Kind.Enum)) + is MiniTypeAliasDecl -> add(CompletionItem(d.name, Kind.TypeAlias, typeText = typeOf(d.target))) is MiniValDecl -> add(CompletionItem(d.name, Kind.Value, typeText = typeOf(d.type))) // else -> add(CompletionItem(d.name, Kind.Value)) } @@ -289,6 +297,10 @@ object CompletionEngineLight { val ci = CompletionItem(name, Kind.Field, typeText = typeOf(chosen.type), priority = groupPriority) if (ci.name.startsWith(prefix, true)) out += ci } + is MiniMemberTypeAliasDecl -> { + val ci = CompletionItem(name, Kind.TypeAlias, typeText = typeOf(rep.target), priority = groupPriority) + if (ci.name.startsWith(prefix, true)) out += ci + } is MiniInitDecl -> {} } } @@ -317,6 +329,8 @@ object CompletionEngineLight { } is MiniMemberValDecl -> CompletionItem(name, Kind.Field, typeText = typeOf(m.type), priority = 50.0) is MiniValDecl -> CompletionItem(name, Kind.Field, typeText = typeOf(m.type), priority = 50.0) + is MiniMemberTypeAliasDecl -> CompletionItem(name, Kind.TypeAlias, typeText = typeOf(m.target), priority = 50.0) + is MiniTypeAliasDecl -> CompletionItem(name, Kind.TypeAlias, typeText = typeOf(m.target), priority = 50.0) else -> CompletionItem(name, Kind.Method, tailText = "()", typeText = null, priority = 50.0) } if (ci.name.startsWith(prefix, true)) { 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 1d4c27e..1ed69e1 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt @@ -34,6 +34,7 @@ object DocLookupUtils { is MiniFunDecl -> "Function" is MiniClassDecl -> "Class" is MiniEnumDecl -> "Enum" + is MiniTypeAliasDecl -> "TypeAlias" is MiniValDecl -> if (d.mutable) "Variable" else "Value" } return d.name to kind @@ -87,6 +88,7 @@ object DocLookupUtils { val kind = when (m) { is MiniMemberFunDecl -> "Function" is MiniMemberValDecl -> if (m.isStatic) "Value" else (if (m.mutable) "Variable" else "Value") + is MiniMemberTypeAliasDecl -> "TypeAlias" is MiniInitDecl -> "Initializer" } return m.name to kind @@ -119,6 +121,7 @@ object DocLookupUtils { return when (d) { is MiniValDecl -> d.type ?: if (text != null && imported != null) inferTypeRefForVal(d, text, imported, mini) else null is MiniFunDecl -> d.returnType + is MiniTypeAliasDecl -> d.target else -> null } } @@ -142,6 +145,7 @@ object DocLookupUtils { is MiniMemberValDecl -> m.type ?: if (text != null && imported != null) { inferTypeRefFromInitRange(m.initRange, m.nameStart, text, imported, mini) } else null + is MiniMemberTypeAliasDecl -> m.target else -> null } @@ -436,7 +440,10 @@ object DocLookupUtils { val type = findTypeByRange(mini, sym.name, sym.declStart, text, imported) 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 + if (sym.kind == net.sergeych.lyng.binding.SymbolKind.Class || + sym.kind == net.sergeych.lyng.binding.SymbolKind.Enum || + sym.kind == net.sergeych.lyng.binding.SymbolKind.TypeAlias + ) return sym.name } } } @@ -451,6 +458,7 @@ object DocLookupUtils { return when (d) { is MiniClassDecl -> d.name is MiniEnumDecl -> d.name + is MiniTypeAliasDecl -> d.name is MiniValDecl -> simpleClassNameOf(d.type ?: inferTypeRefForVal(d, text, imported, mini)) is MiniFunDecl -> simpleClassNameOf(d.returnType) } @@ -565,6 +573,7 @@ object DocLookupUtils { val rt = when (mm) { is MiniMemberFunDecl -> mm.returnType is MiniMemberValDecl -> mm.type + is MiniMemberTypeAliasDecl -> mm.target else -> null } return simpleClassNameOf(rt) @@ -580,8 +589,10 @@ object DocLookupUtils { val rt = when (m) { is MiniMemberFunDecl -> m.returnType is MiniMemberValDecl -> m.type + is MiniMemberTypeAliasDecl -> m.target is MiniFunDecl -> m.returnType is MiniValDecl -> m.type + is MiniTypeAliasDecl -> m.target else -> null } simpleClassNameOf(rt) @@ -921,6 +932,7 @@ object DocLookupUtils { return when (val m = resolved.second) { is MiniMemberFunDecl -> m.returnType is MiniMemberValDecl -> m.type ?: inferTypeRefFromInitRange(m.initRange, m.nameStart, fullText, imported, mini) + is MiniMemberTypeAliasDecl -> m.target else -> null } } @@ -943,6 +955,7 @@ object DocLookupUtils { is MiniEnumDecl -> syntheticTypeRef(d.name) is MiniValDecl -> d.type ?: inferTypeRefForVal(d, fullText, imported, mini) is MiniFunDecl -> d.returnType + is MiniTypeAliasDecl -> d.target } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt index d1ced99..6757ae7 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt @@ -241,6 +241,17 @@ data class MiniEnumDecl( val entryPositions: List = emptyList() ) : MiniDecl +data class MiniTypeAliasDecl( + override val range: MiniRange, + override val name: String, + val typeParams: List, + val target: MiniTypeRef?, + override val doc: MiniDoc?, + override val nameStart: Pos, + override val isExtern: Boolean = false, + override val isStatic: Boolean = false, +) : MiniDecl + data class MiniCtorField( val name: String, val mutable: Boolean, @@ -290,6 +301,17 @@ data class MiniMemberValDecl( override val isExtern: Boolean = false, ) : MiniMemberDecl +data class MiniMemberTypeAliasDecl( + override val range: MiniRange, + override val name: String, + val typeParams: List, + val target: MiniTypeRef?, + override val doc: MiniDoc?, + override val nameStart: Pos, + override val isStatic: Boolean = false, + override val isExtern: Boolean = false, +) : MiniMemberDecl + data class MiniInitDecl( override val range: MiniRange, override val nameStart: Pos, @@ -319,6 +341,7 @@ interface MiniAstSink { fun onInitDecl(node: MiniInitDecl) {} fun onClassDecl(node: MiniClassDecl) {} fun onEnumDecl(node: MiniEnumDecl) {} + fun onTypeAliasDecl(node: MiniTypeAliasDecl) {} fun onBlock(node: MiniBlock) {} fun onIdentifier(node: MiniIdentifier) {} @@ -489,6 +512,41 @@ class MiniAstBuilder : MiniAstSink { lastDoc = null } + override fun onTypeAliasDecl(node: MiniTypeAliasDecl) { + val attach = node.copy(doc = node.doc ?: lastDoc) + val currentClass = classStack.lastOrNull() + if (currentClass != null && functionDepth == 0) { + val member = MiniMemberTypeAliasDecl( + range = attach.range, + name = attach.name, + typeParams = attach.typeParams, + target = attach.target, + doc = attach.doc, + nameStart = attach.nameStart, + isStatic = attach.isStatic, + isExtern = attach.isExtern + ) + val existing = currentClass.members.filterIsInstance() + .find { it.name == attach.name && it.nameStart == attach.nameStart } + val updatedMembers = if (existing != null) { + currentClass.members.map { if (it === existing) member else it } + } else { + currentClass.members + member + } + classStack.removeLast() + classStack.addLast(currentClass.copy(members = updatedMembers)) + } else { + val existing = currentScript?.declarations?.find { it.name == attach.name && it.nameStart == attach.nameStart } + if (existing != null) { + val idx = currentScript?.declarations?.indexOf(existing) ?: -1 + if (idx >= 0) currentScript?.declarations?.set(idx, attach) + } else { + currentScript?.declarations?.add(attach) + } + } + lastDoc = null + } + override fun onBlock(node: MiniBlock) { blocks.addLast(node) } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/tools/LyngLanguageTools.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/tools/LyngLanguageTools.kt new file mode 100644 index 0000000..8aac41f --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/tools/LyngLanguageTools.kt @@ -0,0 +1,467 @@ +/* + * 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.tools + +import net.sergeych.lyng.* +import net.sergeych.lyng.binding.Binder +import net.sergeych.lyng.binding.BindingSnapshot +import net.sergeych.lyng.binding.SymbolKind +import net.sergeych.lyng.bytecode.BytecodeStatement +import net.sergeych.lyng.bytecode.CmdDisassembler +import net.sergeych.lyng.format.LyngFormatConfig +import net.sergeych.lyng.format.LyngFormatter +import net.sergeych.lyng.highlight.HighlightSpan +import net.sergeych.lyng.highlight.SimpleLyngHighlighter +import net.sergeych.lyng.highlight.TextRange +import net.sergeych.lyng.highlight.offsetOf +import net.sergeych.lyng.miniast.* +import net.sergeych.lyng.obj.ObjClass +import net.sergeych.lyng.pacman.ImportProvider +import net.sergeych.lyng.resolution.ResolutionCollector +import net.sergeych.lyng.resolution.ResolutionReport + +data class LyngAnalysisRequest( + val text: String, + val fileName: String = "", + val importProvider: ImportProvider = Script.defaultImportManager, + val seedScope: Scope? = null +) + +enum class LyngDiagnosticSeverity { Error, Warning } + +data class LyngDiagnostic( + val message: String, + val severity: LyngDiagnosticSeverity, + val range: TextRange? = null, + val pos: Pos? = null +) + +data class LyngAnalysisResult( + val source: Source, + val text: String, + val mini: MiniScript?, + val binding: BindingSnapshot?, + val resolution: ResolutionReport?, + val importedModules: List, + val diagnostics: List, + val lexicalHighlights: List +) + +data class LyngSymbolTarget( + val name: String, + val kind: SymbolKind, + val range: TextRange, + val containerName: String? = null +) + +data class LyngSymbolInfo( + val target: LyngSymbolTarget, + val signature: String? = null, + val doc: MiniDoc? = null +) + +enum class LyngSemanticKind { + Function, + Class, + Enum, + TypeAlias, + Value, + Variable, + Parameter, + TypeRef, + EnumConstant +} + +data class LyngSemanticSpan( + val range: TextRange, + val kind: LyngSemanticKind +) + +object LyngLanguageTools { + + suspend fun analyze(request: LyngAnalysisRequest): LyngAnalysisResult { + val source = Source(request.fileName, request.text) + val miniSink = MiniAstBuilder() + val resolutionCollector = ResolutionCollector(source.fileName) + val diagnostics = ArrayList() + + try { + Compiler.compileWithResolution( + source, + request.importProvider, + miniSink = miniSink, + resolutionSink = resolutionCollector, + useBytecodeStatements = false, + allowUnresolvedRefs = true, + seedScope = request.seedScope + ) + } catch (t: Throwable) { + val pos = (t as? net.sergeych.lyng.ScriptError)?.pos + diagnostics += LyngDiagnostic( + message = t.message ?: t.toString(), + severity = LyngDiagnosticSeverity.Error, + range = pos?.let { posToRange(source, it) }, + pos = pos + ) + } + + val mini = miniSink.build() + val binding = mini?.let { Binder.bind(request.text, it) } + val report = try { resolutionCollector.buildReport() } catch (_: Throwable) { null } + + report?.errors?.forEach { err -> + diagnostics += LyngDiagnostic( + message = err.message, + severity = LyngDiagnosticSeverity.Error, + range = posToRange(source, err.pos), + pos = err.pos + ) + } + report?.warnings?.forEach { warn -> + diagnostics += LyngDiagnostic( + message = warn.message, + severity = LyngDiagnosticSeverity.Warning, + range = posToRange(source, warn.pos), + pos = warn.pos + ) + } + + val imports = when { + mini != null -> DocLookupUtils.canonicalImportedModules(mini, request.text) + else -> DocLookupUtils.extractImportsFromText(request.text).toMutableList().apply { add("lyng.stdlib") }.distinct() + } + + val lexical = try { SimpleLyngHighlighter().highlight(request.text) } catch (_: Throwable) { emptyList() } + + return LyngAnalysisResult( + source = source, + text = request.text, + mini = mini, + binding = binding, + resolution = report, + importedModules = imports, + diagnostics = diagnostics, + lexicalHighlights = lexical + ) + } + + suspend fun analyze(text: String, fileName: String = ""): LyngAnalysisResult = + analyze(LyngAnalysisRequest(text = text, fileName = fileName)) + + fun format(text: String, config: LyngFormatConfig = LyngFormatConfig()): String = + LyngFormatter.format(text, config) + + fun lexicalHighlights(text: String): List = + SimpleLyngHighlighter().highlight(text) + + fun semanticHighlights(analysis: LyngAnalysisResult): List { + val mini = analysis.mini ?: return emptyList() + val source = analysis.source + val out = ArrayList(128) + val covered = HashSet>() + + fun addRange(start: Int, end: Int, kind: LyngSemanticKind) { + if (start < 0 || end <= start || end > analysis.text.length) return + val key = start to end + if (covered.add(key)) out += LyngSemanticSpan(TextRange(start, end), kind) + } + + fun addName(pos: Pos, name: String, kind: LyngSemanticKind) { + val s = source.offsetOf(pos) + addRange(s, s + name.length, kind) + } + + fun addTypeSegments(t: MiniTypeRef?) { + when (t) { + is MiniTypeName -> t.segments.forEach { seg -> + addName(seg.range.start, seg.name, LyngSemanticKind.TypeRef) + } + is MiniGenericType -> { + addTypeSegments(t.base) + t.args.forEach { addTypeSegments(it) } + } + is MiniFunctionType -> { + t.receiver?.let { addTypeSegments(it) } + t.params.forEach { addTypeSegments(it) } + addTypeSegments(t.returnType) + } + is MiniTypeVar -> { + addRange(source.offsetOf(t.range.start), source.offsetOf(t.range.end), LyngSemanticKind.TypeRef) + } + is MiniTypeUnion -> { + t.options.forEach { addTypeSegments(it) } + } + is MiniTypeIntersection -> { + t.options.forEach { addTypeSegments(it) } + } + null -> {} + } + } + + fun addDeclTypeSegments(d: MiniDecl) { + when (d) { + is MiniFunDecl -> { + addTypeSegments(d.returnType) + d.params.forEach { addTypeSegments(it.type) } + addTypeSegments(d.receiver) + } + is MiniValDecl -> { + addTypeSegments(d.type) + addTypeSegments(d.receiver) + } + is MiniClassDecl -> { + d.ctorFields.forEach { addTypeSegments(it.type) } + d.classFields.forEach { addTypeSegments(it.type) } + d.members.forEach { m -> + when (m) { + is MiniMemberFunDecl -> { + addTypeSegments(m.returnType) + m.params.forEach { addTypeSegments(it.type) } + } + is MiniMemberValDecl -> addTypeSegments(m.type) + is MiniMemberTypeAliasDecl -> addTypeSegments(m.target) + is MiniInitDecl -> {} + } + } + } + is MiniEnumDecl -> {} + is MiniTypeAliasDecl -> addTypeSegments(d.target) + } + } + + for (d in mini.declarations) { + when (d) { + is MiniFunDecl -> addName(d.nameStart, d.name, LyngSemanticKind.Function) + is MiniClassDecl -> addName(d.nameStart, d.name, LyngSemanticKind.Class) + is MiniEnumDecl -> addName(d.nameStart, d.name, LyngSemanticKind.Enum) + is MiniValDecl -> addName(d.nameStart, d.name, if (d.mutable) LyngSemanticKind.Variable else LyngSemanticKind.Value) + is MiniTypeAliasDecl -> addName(d.nameStart, d.name, LyngSemanticKind.TypeAlias) + } + addDeclTypeSegments(d) + } + + mini.imports.forEach { imp -> + imp.segments.forEach { seg -> + addRange(source.offsetOf(seg.range.start), source.offsetOf(seg.range.end), LyngSemanticKind.TypeRef) + } + } + + fun addParams(params: List) { + params.forEach { p -> addName(p.nameStart, p.name, LyngSemanticKind.Parameter) } + } + mini.declarations.forEach { d -> + when (d) { + is MiniFunDecl -> addParams(d.params) + is MiniClassDecl -> d.members.filterIsInstance().forEach { addParams(it.params) } + else -> {} + } + } + + mini.declarations.filterIsInstance().forEach { en -> + en.entries.zip(en.entryPositions).forEach { (name, pos) -> + addName(pos, name, LyngSemanticKind.EnumConstant) + } + } + + analysis.binding?.let { binding -> + val declKeys = binding.symbols.map { it.declStart to it.declEnd }.toSet() + for (ref in binding.references) { + if (declKeys.contains(ref.start to ref.end)) continue + val sym = binding.symbols.firstOrNull { it.id == ref.symbolId } ?: continue + val kind = when (sym.kind) { + SymbolKind.Function -> LyngSemanticKind.Function + SymbolKind.Class -> LyngSemanticKind.Class + SymbolKind.Enum -> LyngSemanticKind.Enum + SymbolKind.TypeAlias -> LyngSemanticKind.TypeAlias + SymbolKind.Value -> LyngSemanticKind.Value + SymbolKind.Variable -> LyngSemanticKind.Variable + SymbolKind.Parameter -> LyngSemanticKind.Parameter + } + addRange(ref.start, ref.end, kind) + } + } + + return out.sortedBy { it.range.start } + } + + suspend fun completions(text: String, offset: Int, analysis: LyngAnalysisResult? = null): List { + val mini = analysis?.mini + val binding = analysis?.binding + StdlibDocsBootstrap.ensure() + return CompletionEngineLight.completeSuspend(text, offset, mini, binding) + } + + fun definitionAt(analysis: LyngAnalysisResult, offset: Int): LyngSymbolTarget? { + val binding = analysis.binding ?: return null + val sym = binding.symbols.firstOrNull { offset in it.declStart until it.declEnd } + ?: binding.references.firstOrNull { offset in it.start until it.end } + ?.let { ref -> binding.symbols.firstOrNull { it.id == ref.symbolId } } + ?: return null + val containerName = sym.containerId?.let { cid -> binding.symbols.firstOrNull { it.id == cid }?.name } + return LyngSymbolTarget( + name = sym.name, + kind = sym.kind, + range = TextRange(sym.declStart, sym.declEnd), + containerName = containerName + ) + } + + fun usagesAt(analysis: LyngAnalysisResult, offset: Int, includeDeclaration: Boolean = false): List { + val binding = analysis.binding ?: return emptyList() + val sym = binding.symbols.firstOrNull { offset in it.declStart until it.declEnd } + ?: binding.references.firstOrNull { offset in it.start until it.end } + ?.let { ref -> binding.symbols.firstOrNull { it.id == ref.symbolId } } + ?: return emptyList() + val ranges = binding.references.filter { it.symbolId == sym.id }.map { TextRange(it.start, it.end) }.toMutableList() + if (includeDeclaration) ranges.add(TextRange(sym.declStart, sym.declEnd)) + return ranges + } + + fun docAt(analysis: LyngAnalysisResult, offset: Int): LyngSymbolInfo? { + StdlibDocsBootstrap.ensure() + val target = definitionAt(analysis, offset) ?: return null + val mini = analysis.mini + val imported = analysis.importedModules + val name = target.name + + val local = mini?.let { findLocalDecl(it, analysis.text, name, target.range.start) } + if (local != null) { + val signature = signatureOf(local.first, local.second) + return LyngSymbolInfo(target, signature = signature, doc = local.first.doc) + } + + if (target.containerName != null) { + val member = DocLookupUtils.resolveMemberWithInheritance(imported, target.containerName, name, mini) + if (member != null) { + val signature = signatureOf(member.second, member.first) + return LyngSymbolInfo(target, signature = signature, doc = member.second.doc) + } + } + + for (mod in imported) { + val decl = BuiltinDocRegistry.docsForModule(mod).firstOrNull { it.name == name } + if (decl != null) { + val signature = signatureOf(decl, null) + return LyngSymbolInfo(target, signature = signature, doc = decl.doc) + } + } + + return LyngSymbolInfo(target, signature = null, doc = null) + } + + suspend fun disassembleSymbol( + code: String, + symbol: String, + importProvider: ImportProvider = Script.defaultImportManager + ): String { + val script = Compiler.compile(code.toSource(), importProvider) + val scope = importProvider.newStdScope(Pos.builtIn) + script.execute(scope) + val (container, member) = splitMember(symbol) + if (member == null) return disassembleFromScope(scope, container) + val rec = scope.get(container) ?: return "$symbol is not found" + val cls = rec.value as? ObjClass ?: return "$container is not a class" + val classScope = cls.classScope ?: return "$container has no class scope" + return disassembleFromScope(classScope, member) + } + + private fun posToRange(source: Source, pos: Pos): TextRange { + val s = source.offsetOf(pos) + return TextRange(s, (s + 1).coerceAtMost(source.text.length)) + } + + private fun findLocalDecl(mini: MiniScript, text: String, name: String, declStart: Int): Pair? { + val src = mini.range.start.source + fun matches(p: Pos, len: Int) = src.offsetOf(p).let { s -> s == declStart && len > 0 } + + for (d in mini.declarations) { + if (d.name == name && matches(d.nameStart, d.name.length)) return d to null + if (d is MiniClassDecl) { + d.members.forEach { m -> + if (m.name == name && matches(m.nameStart, m.name.length)) return m to d.name + } + d.ctorFields.firstOrNull { it.name == name && matches(it.nameStart, it.name.length) }?.let { + return DocLookupUtils.toMemberVal(it) to d.name + } + d.classFields.firstOrNull { it.name == name && matches(it.nameStart, it.name.length) }?.let { + return DocLookupUtils.toMemberVal(it) to d.name + } + } + } + return null + } + + private fun signatureOf(decl: MiniNamedDecl, ownerClass: String?): String? { + val owner = ownerClass?.let { "$it." } ?: "" + return when (decl) { + is MiniFunDecl -> { + val params = decl.params.joinToString(", ") { it.name + typeSuffix(it.type) } + val ret = typeSuffix(decl.returnType) + "fun $owner${decl.name}($params)$ret" + } + is MiniMemberFunDecl -> { + val params = decl.params.joinToString(", ") { it.name + typeSuffix(it.type) } + val ret = typeSuffix(decl.returnType) + "fun $owner${decl.name}($params)$ret" + } + is MiniValDecl -> { + val kw = if (decl.mutable) "var" else "val" + "$kw $owner${decl.name}${typeSuffix(decl.type)}" + } + is MiniMemberValDecl -> { + val kw = if (decl.mutable) "var" else "val" + "$kw $owner${decl.name}${typeSuffix(decl.type)}" + } + is MiniClassDecl -> { + val bases = if (decl.bases.isEmpty()) "" else ": " + decl.bases.joinToString(", ") + "class ${decl.name}$bases" + } + is MiniEnumDecl -> "enum ${decl.name}" + is MiniTypeAliasDecl -> { + val tp = if (decl.typeParams.isEmpty()) "" else "<" + decl.typeParams.joinToString(", ") + ">" + "type ${decl.name}$tp = ${DocLookupUtils.typeOf(decl.target)}" + } + is MiniMemberTypeAliasDecl -> { + val tp = if (decl.typeParams.isEmpty()) "" else "<" + decl.typeParams.joinToString(", ") + ">" + "type $owner${decl.name}$tp = ${DocLookupUtils.typeOf(decl.target)}" + } + else -> null + } + } + + private fun typeSuffix(type: MiniTypeRef?): String = + type?.let { ": ${DocLookupUtils.typeOf(it)}" } ?: "" + + private fun splitMember(symbol: String): Pair { + val idx = symbol.lastIndexOf('.') + return if (idx >= 0 && idx + 1 < symbol.length) { + symbol.substring(0, idx) to symbol.substring(idx + 1) + } else { + symbol to null + } + } + + private fun disassembleFromScope(scope: Scope, name: String): String { + val record = scope.get(name) ?: return "$name is not found" + val stmt = record.value as? net.sergeych.lyng.Statement ?: return "$name is not a compiled body" + val bytecode = (stmt as? BytecodeStatement)?.bytecodeFunction() + ?: (stmt as? BytecodeBodyProvider)?.bytecodeBody()?.bytecodeFunction() + ?: return "$name is not a compiled body" + return CmdDisassembler.disassemble(bytecode) + } +} diff --git a/lynglib/src/commonTest/kotlin/StdlibTest.kt b/lynglib/src/commonTest/kotlin/StdlibTest.kt index b88154d..afa0b7d 100644 --- a/lynglib/src/commonTest/kotlin/StdlibTest.kt +++ b/lynglib/src/commonTest/kotlin/StdlibTest.kt @@ -131,4 +131,16 @@ class StdlibTest { assertEquals(31, p.age) """.trimIndent()) } + +// @Test +// fun testFunFromSample() = runTest { +// range should be iterable if it is intrange +// eval(""" +// val data = 1..5 // or [1, 2, 3, 4, 5] +// fun test() { +// data.filter { it % 2 == 0 }.map { it * it } +// } +// test() +// """.trimIndent()) +// } } diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/tools/LyngLanguageToolsTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/tools/LyngLanguageToolsTest.kt new file mode 100644 index 0000000..c2a4de8 --- /dev/null +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/tools/LyngLanguageToolsTest.kt @@ -0,0 +1,113 @@ +/* + * 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.tools + +import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.miniast.MiniClassDecl +import net.sergeych.lyng.miniast.MiniMemberTypeAliasDecl +import net.sergeych.lyng.stdlib_included.rootLyng +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class LyngLanguageToolsTest { + + @Test + fun languageTools_dryRun_rootLyng_hasNoErrors() = runTest { + val res = LyngLanguageTools.analyze(rootLyng, "root.lyng") + assertNotNull(res.mini, "root.lyng should build Mini-AST") + assertTrue(res.lexicalHighlights.isNotEmpty(), "root.lyng should produce lexical highlights") + } + + @Test + fun languageTools_tracks_inner_and_type_aliases() = runTest { + val code = """ + /** Box docs */ + type Box = List + + class Outer { + type Alias = Box + class Inner { + val value: Int = 1 + } + enum Kind { A, B } + object Obj { val flag = true } + } + """.trimIndent() + val res = LyngLanguageTools.analyze(code, "inner.lyng") + val mini = res.mini + assertNotNull(mini, "Mini-AST must be built") + + val outer = mini.declarations.filterIsInstance().firstOrNull { it.name == "Outer" } + assertNotNull(outer, "Outer class should be captured") + val aliasMember = outer.members.filterIsInstance().firstOrNull { it.name == "Alias" } + assertNotNull(aliasMember, "Inner type alias should be captured as a class member") + + val sem = LyngLanguageTools.semanticHighlights(res) + assertTrue(sem.any { it.kind == LyngSemanticKind.TypeAlias }, "Type aliases should be part of semantic highlights") + } + + @Test + fun languageTools_completion_and_docs_for_type_alias() = runTest { + val code = """ + /** Box docs */ + type Box = List + val x: Box = [1] + + """.trimIndent() + val caret = code.indexOf("") + val text = code.replace("", "") + val res = LyngLanguageTools.analyze(text, "alias.lyng") + val items = LyngLanguageTools.completions(text, caret, res) + assertTrue(items.any { it.name == "Box" }, "Completion should include Box type alias") + + val aliasOffset = text.indexOf("Box") + val doc = LyngLanguageTools.docAt(res, aliasOffset) + assertNotNull(doc, "Docs should resolve for Box") + assertEquals("Box", doc.target.name) + assertEquals("Box docs", doc.doc?.summary) + } + + @Test + fun languageTools_definition_and_usages() = runTest { + val code = """ + val answer = 42 + println(answer) + answer + """.trimIndent() + val res = LyngLanguageTools.analyze(code, "usage.lyng") + val usageOffset = code.lastIndexOf("answer") + val def = LyngLanguageTools.definitionAt(res, usageOffset) + assertNotNull(def, "Definition should resolve") + assertEquals("answer", def.name) + val usages = LyngLanguageTools.usagesAt(res, usageOffset) + assertTrue(usages.size >= 2, "Expected at least two usages, got ${usages.size}") + } + + @Test + fun languageTools_disassemble_symbol() = runTest { + val code = """ + fun add(a: Int, b: Int): Int { + a + b + } + """.trimIndent() + val dis = LyngLanguageTools.disassembleSymbol(code, "add") + assertTrue(!dis.contains("not a compiled body"), "Disassembly should be produced, got: $dis") + } +} diff --git a/lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Editor.kt b/lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Editor.kt index 69075ee..b802545 100644 --- a/lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Editor.kt +++ b/lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Editor.kt @@ -19,9 +19,17 @@ package net.sergeych.lyngweb import androidx.compose.runtime.* import kotlinx.browser.window +import kotlinx.coroutines.launch +import net.sergeych.lyng.highlight.TextRange +import net.sergeych.lyng.miniast.CompletionItem +import net.sergeych.lyng.tools.LyngAnalysisResult +import net.sergeych.lyng.tools.LyngSymbolInfo +import net.sergeych.lyng.tools.LyngSymbolTarget import org.jetbrains.compose.web.attributes.placeholder import org.jetbrains.compose.web.dom.Div import org.jetbrains.compose.web.events.SyntheticKeyboardEvent +import org.w3c.dom.CanvasRenderingContext2D +import org.w3c.dom.HTMLCanvasElement import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLTextAreaElement @@ -47,12 +55,20 @@ fun EditorWithOverlay( setCode: (String) -> Unit, tabSize: Int = 4, onKeyDown: ((SyntheticKeyboardEvent) -> Unit)? = null, + onAnalysisReady: ((LyngAnalysisResult) -> Unit)? = null, + onCompletionRequested: ((Int, List) -> Unit)? = null, + onDefinitionResolved: ((Int, LyngSymbolTarget?) -> Unit)? = null, + onUsagesResolved: ((Int, List) -> Unit)? = null, + onDocRequested: ((Int, LyngSymbolInfo?) -> Unit)? = null, // New sizing controls minRows: Int = 6, maxRows: Int? = null, autoGrow: Boolean = false, ) { + val scope = rememberCoroutineScope() + var containerEl by remember { mutableStateOf(null) } var overlayEl by remember { mutableStateOf(null) } + var diagOverlayEl by remember { mutableStateOf(null) } var taEl by remember { mutableStateOf(null) } var lastGoodHtml by remember { mutableStateOf(null) } var lastGoodText by remember { mutableStateOf(null) } @@ -62,9 +78,16 @@ fun EditorWithOverlay( var pendingScrollLeft by remember { mutableStateOf(null) } var cachedLineHeight by remember { mutableStateOf(null) } var cachedVInsets by remember { mutableStateOf(null) } + var cachedCharWidth by remember { mutableStateOf(null) } + var lastAnalysis by remember { mutableStateOf(null) } + var lastAnalysisText by remember { mutableStateOf(null) } + var lineStarts by remember { mutableStateOf(IntArray(0)) } + var tooltipText by remember { mutableStateOf(null) } + var tooltipX by remember { mutableStateOf(null) } + var tooltipY by remember { mutableStateOf(null) } fun ensureMetrics(ta: HTMLTextAreaElement) { - if (cachedLineHeight == null || cachedVInsets == null) { + if (cachedLineHeight == null || cachedVInsets == null || cachedCharWidth == null) { val cs = window.getComputedStyle(ta) val lhStr = cs.getPropertyValue("line-height").trim() val lh = lhStr.removeSuffix("px").toDoubleOrNull() ?: 20.0 @@ -78,6 +101,21 @@ fun EditorWithOverlay( val bb = parsePx("border-bottom-width") cachedLineHeight = lh cachedVInsets = pt + pb + bt + bb + + val canvas = window.document.createElement("canvas") as HTMLCanvasElement + val ctx = canvas.getContext("2d") as? CanvasRenderingContext2D + if (ctx != null) { + val fontSize = cs.fontSize + val fontFamily = cs.fontFamily + val fontWeight = cs.fontWeight + val fontStyle = cs.fontStyle + ctx.font = "$fontStyle $fontWeight $fontSize $fontFamily" + val m = ctx.measureText("M") + val w = if (m.width > 0.0) m.width else 8.0 + cachedCharWidth = w + } else { + cachedCharWidth = 8.0 + } } } @@ -102,6 +140,63 @@ fun EditorWithOverlay( ta.style.height = "${target}px" } + suspend fun ensureAnalysis(text: String): LyngAnalysisResult { + val cached = lastAnalysis + val cachedText = lastAnalysisText + if (cached != null && cachedText == text) return cached + val analysis = LyngWebTools.analyze(text) + lastAnalysis = analysis + lastAnalysisText = text + onAnalysisReady?.invoke(analysis) + return analysis + } + + fun htmlEscapeLocal(s: String): String = buildString(s.length) { + for (ch in s) when (ch) { + '<' -> append("<") + '>' -> append(">") + '&' -> append("&") + '"' -> append(""") + '\'' -> append("'") + else -> append(ch) + } + } + + fun buildLineStarts(text: String): IntArray { + val starts = ArrayList(maxOf(1, text.length / 16)) + starts.add(0) + for (i in text.indices) { + if (text[i] == '\n') starts.add(i + 1) + } + return starts.toIntArray() + } + + fun offsetFromMouse(ta: HTMLTextAreaElement, clientX: Double, clientY: Double): Int? { + ensureMetrics(ta) + val rect = ta.getBoundingClientRect() + val lineHeight = cachedLineHeight ?: return null + val charWidth = cachedCharWidth ?: return null + val cs = window.getComputedStyle(ta) + fun parsePx(name: String): Double { + val v = cs.getPropertyValue(name).trim().removeSuffix("px").toDoubleOrNull() + return v ?: 0.0 + } + val padLeft = parsePx("padding-left") + parsePx("border-left-width") + val padTop = parsePx("padding-top") + parsePx("border-top-width") + val x = clientX - rect.left + ta.scrollLeft - padLeft + val y = clientY - rect.top + ta.scrollTop - padTop + if (y < 0) return 0 + val lineIdx = (y / lineHeight).toInt().coerceAtLeast(0) + if (lineStarts.isEmpty()) return 0 + val actualLineIdx = lineIdx.coerceAtMost(lineStarts.size - 1) + val lineStart = lineStarts[actualLineIdx] + val lineEnd = if (actualLineIdx + 1 < lineStarts.size) lineStarts[actualLineIdx + 1] - 1 else code.length + val lineLen = (lineEnd - lineStart).coerceAtLeast(0) + val col = (x / charWidth).toInt().coerceAtLeast(0) + val clampedCol = col.coerceAtMost(lineLen) + return (lineStart + clampedCol).coerceIn(0, code.length) + } + // Update overlay HTML whenever code changes LaunchedEffect(code) { fun clamp(i: Int, lo: Int, hi: Int): Int = if (i < lo) lo else if (i > hi) hi else i @@ -110,16 +205,6 @@ fun EditorWithOverlay( val e = clamp(end, 0, text.length) return if (e <= s) "" else text.substring(s, e) } - fun htmlEscapeLocal(s: String): String = buildString(s.length) { - for (ch in s) when (ch) { - '<' -> append("<") - '>' -> append(">") - '&' -> append("&") - '"' -> append(""") - '\'' -> append("'") - else -> append(ch) - } - } fun trimHtmlToTextPrefix(html: String, prefixChars: Int): String { if (prefixChars <= 0) return "" @@ -188,10 +273,103 @@ fun EditorWithOverlay( val sl = pendingScrollLeft ?: (taEl?.scrollLeft ?: 0.0) overlayEl?.scrollTop = st overlayEl?.scrollLeft = sl + diagOverlayEl?.scrollTop = st + diagOverlayEl?.scrollLeft = sl pendingScrollTop = null pendingScrollLeft = null // If text changed and autoGrow enabled, adjust height adjustTextareaHeight() + lineStarts = buildLineStarts(code) + } + + fun buildDiagnosticsHtml( + text: String, + diagnostics: List + ): String { + if (diagnostics.isEmpty()) return "" + val ranges = diagnostics.mapNotNull { d -> + val r = d.range ?: return@mapNotNull null + if (r.start < 0 || r.endExclusive <= r.start || r.endExclusive > text.length) return@mapNotNull null + Triple(r, d.severity, d.message) + }.sortedBy { it.first.start } + if (ranges.isEmpty()) return "" + val out = StringBuilder(text.length + 64) + var cursor = 0 + for ((range, severity, message) in ranges) { + if (range.start < cursor) continue + if (cursor < range.start) { + out.append(htmlEscapeLocal(text.substring(cursor, range.start))) + } + val color = when (severity) { + net.sergeych.lyng.tools.LyngDiagnosticSeverity.Error -> "#dc3545" + net.sergeych.lyng.tools.LyngDiagnosticSeverity.Warning -> "#ffc107" + } + val seg = htmlEscapeLocal(text.substring(range.start, range.endExclusive)) + val tip = htmlEscapeLocal(message).replace("\"", """) + out.append("") + out.append(seg) + out.append("") + cursor = range.endExclusive + } + if (cursor < text.length) out.append(htmlEscapeLocal(text.substring(cursor))) + return out.toString() + } + + fun diagnosticMessageAt(offset: Int, analysis: LyngAnalysisResult?): String? { + val list = analysis?.diagnostics ?: return null + for (d in list) { + val r = d.range ?: continue + if (offset in r.start until r.endExclusive) return d.message + } + return null + } + + fun updateCaretTooltip() { + val ta = taEl ?: return + val offset = ta.selectionStart ?: return + val msg = diagnosticMessageAt(offset, lastAnalysis) + if (msg.isNullOrBlank()) { + ta.removeAttribute("title") + } else { + ta.setAttribute("title", msg) + } + } + + fun updateHoverTooltip(clientX: Double, clientY: Double) { + val ta = taEl ?: return + val offset = offsetFromMouse(ta, clientX, clientY) ?: return + val msg = diagnosticMessageAt(offset, lastAnalysis) + if (msg.isNullOrBlank()) { + tooltipText = null + return + } + val container = containerEl ?: return + val rect = container.getBoundingClientRect() + tooltipText = msg + tooltipX = (clientX - rect.left + 12.0).coerceAtLeast(0.0) + tooltipY = (clientY - rect.top + 12.0).coerceAtLeast(0.0) + } + + LaunchedEffect(code, lastAnalysis) { + val analysis = lastAnalysis ?: return@LaunchedEffect + if (lastAnalysisText != code) { + diagOverlayEl?.innerHTML = htmlEscapeLocal(code) + updateCaretTooltip() + return@LaunchedEffect + } + val html = buildDiagnosticsHtml(code, analysis.diagnostics) + val content = if (html.isEmpty()) htmlEscapeLocal(code) else html + diagOverlayEl?.innerHTML = content + updateCaretTooltip() + } + + LaunchedEffect(code, onAnalysisReady) { + if (onAnalysisReady == null) return@LaunchedEffect + try { + ensureAnalysis(code) + } catch (_: Throwable) { + } } fun setSelection(start: Int, end: Int = start) { @@ -206,6 +384,10 @@ fun EditorWithOverlay( // avoid external CSS dependency: ensure base positioning inline classes("position-relative") attr("style", "position:relative;") + ref { it -> + containerEl = it + onDispose { if (containerEl === it) containerEl = null } + } }) { // Overlay: highlighted code org.jetbrains.compose.web.dom.Div({ @@ -226,6 +408,23 @@ fun EditorWithOverlay( } }) {} + // Diagnostics overlay: transparent text with wavy underlines + org.jetbrains.compose.web.dom.Div({ + attr( + "style", + buildString { + append("position:absolute; left:0; top:0; right:0; bottom:0;") + append("overflow:auto; box-sizing:border-box; white-space:pre-wrap; word-break:break-word; tab-size:") + append(tabSize) + append("; margin:0; pointer-events:none; color:transparent;") + } + ) + ref { it -> + diagOverlayEl = it + onDispose { if (diagOverlayEl === it) diagOverlayEl = null } + } + }) {} + // Textarea: user input with transparent text org.jetbrains.compose.web.dom.TextArea(value = code, attrs = { ref { ta -> @@ -269,13 +468,16 @@ fun EditorWithOverlay( val v = (ev.target as HTMLTextAreaElement).value setCode(v) adjustTextareaHeight() + updateCaretTooltip() } onKeyDown { ev -> // bubble to caller first so they may intercept shortcuts onKeyDown?.invoke(ev) + if (ev.defaultPrevented) return@onKeyDown val ta = taEl ?: return@onKeyDown val key = ev.key + val keyLower = key.lowercase() // If user pressed Ctrl/Cmd + Enter, treat it as a shortcut (e.g., Run) // and DO NOT insert a newline here. Let the host handler act. // Also prevent default so the textarea won't add a line. @@ -283,6 +485,48 @@ fun EditorWithOverlay( ev.preventDefault() return@onKeyDown } + if (ev.ctrlKey || ev.metaKey) { + val offset = ta.selectionStart ?: 0 + val text = ta.value + when { + (key == " " || keyLower == "space" || keyLower == "spacebar") && onCompletionRequested != null -> { + ev.preventDefault() + scope.launch { + val analysis = ensureAnalysis(text) + val items = LyngWebTools.completions(text, offset, analysis) + onCompletionRequested(offset, items) + } + return@onKeyDown + } + keyLower == "b" && onDefinitionResolved != null -> { + ev.preventDefault() + scope.launch { + val analysis = ensureAnalysis(text) + val target = LyngWebTools.definitionAt(analysis, offset) + onDefinitionResolved(offset, target) + } + return@onKeyDown + } + ev.shiftKey && keyLower == "u" && onUsagesResolved != null -> { + ev.preventDefault() + scope.launch { + val analysis = ensureAnalysis(text) + val ranges = LyngWebTools.usagesAt(analysis, offset, includeDeclaration = false) + onUsagesResolved(offset, ranges) + } + return@onKeyDown + } + keyLower == "q" && onDocRequested != null -> { + ev.preventDefault() + scope.launch { + val analysis = ensureAnalysis(text) + val info = LyngWebTools.docAt(analysis, offset) + onDocRequested(offset, info) + } + return@onKeyDown + } + } + } if (key == "Tab" && ev.shiftKey) { // Shift+Tab: outdent current line(s) ev.preventDefault() @@ -336,21 +580,57 @@ fun EditorWithOverlay( } } + onKeyUp { _ -> + updateCaretTooltip() + } + + onMouseUp { _ -> + updateCaretTooltip() + } + + onMouseMove { ev -> + updateHoverTooltip(ev.clientX.toDouble(), ev.clientY.toDouble()) + } + + onMouseLeave { _ -> + tooltipText = null + } + onScroll { ev -> val src = ev.target as? HTMLTextAreaElement ?: return@onScroll overlayEl?.scrollTop = src.scrollTop overlayEl?.scrollLeft = src.scrollLeft + diagOverlayEl?.scrollTop = src.scrollTop + diagOverlayEl?.scrollLeft = src.scrollLeft } }) + if (tooltipText != null && tooltipX != null && tooltipY != null) { + org.jetbrains.compose.web.dom.Div({ + attr( + "style", + buildString { + append("position:absolute; z-index:3; pointer-events:none;") + append("left:").append(tooltipX).append("px; top:").append(tooltipY).append("px;") + append("background:#212529; color:#f8f9fa; padding:4px 6px; border-radius:4px;") + append("font-size:12px; line-height:1.3; max-width:360px; white-space:pre-wrap;") + append("box-shadow:0 4px 10px rgba(0,0,0,.15);") + } + ) + }) { + org.jetbrains.compose.web.dom.Text(tooltipText!!) + } + } + // No built-in action buttons: EditorWithOverlay is a pure editor now } // Ensure overlay typography and paddings mirror the textarea so characters line up 1:1 - LaunchedEffect(taEl, overlayEl) { + LaunchedEffect(taEl, overlayEl, diagOverlayEl) { try { val ta = taEl ?: return@LaunchedEffect val ov = overlayEl ?: return@LaunchedEffect + val diag = diagOverlayEl val cs = window.getComputedStyle(ta) // Best-effort concrete line-height @@ -376,6 +656,7 @@ fun EditorWithOverlay( append("color: var(--bs-body-color);") } ov.setAttribute("style", style) + diag?.setAttribute("style", style + "color:transparent;") // also enforce concrete line-height on textarea to stabilize caret metrics val existing = ta.getAttribute("style") ?: "" if (!existing.contains("line-height") && !lineHeight.isNullOrBlank()) { diff --git a/lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/LyngWebTools.kt b/lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/LyngWebTools.kt new file mode 100644 index 0000000..d0b45a0 --- /dev/null +++ b/lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/LyngWebTools.kt @@ -0,0 +1,55 @@ +/* + * 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.lyngweb + +import net.sergeych.lyng.format.LyngFormatConfig +import net.sergeych.lyng.highlight.TextRange +import net.sergeych.lyng.miniast.CompletionItem +import net.sergeych.lyng.tools.LyngAnalysisResult +import net.sergeych.lyng.tools.LyngLanguageTools +import net.sergeych.lyng.tools.LyngSymbolInfo +import net.sergeych.lyng.tools.LyngSymbolTarget + +/** + * Thin JS-friendly facade for shared Lyng language tooling. + * Keeps web editor/site integrations consistent with IDE tooling behavior. + */ +object LyngWebTools { + suspend fun analyze(text: String, fileName: String = ""): LyngAnalysisResult = + LyngLanguageTools.analyze(text, fileName) + + suspend fun completions(text: String, offset: Int, analysis: LyngAnalysisResult? = null): List { + val a = analysis ?: analyze(text) + return LyngLanguageTools.completions(text, offset, a) + } + + fun definitionAt(analysis: LyngAnalysisResult, offset: Int): LyngSymbolTarget? = + LyngLanguageTools.definitionAt(analysis, offset) + + fun usagesAt(analysis: LyngAnalysisResult, offset: Int, includeDeclaration: Boolean = false): List = + LyngLanguageTools.usagesAt(analysis, offset, includeDeclaration) + + fun docAt(analysis: LyngAnalysisResult, offset: Int): LyngSymbolInfo? = + LyngLanguageTools.docAt(analysis, offset) + + fun format(text: String, config: LyngFormatConfig = LyngFormatConfig()): String = + LyngLanguageTools.format(text, config) + + suspend fun disassembleSymbol(text: String, symbol: String): String = + LyngLanguageTools.disassembleSymbol(text, symbol) +} diff --git a/site/src/jsMain/kotlin/ReferencePage.kt b/site/src/jsMain/kotlin/ReferencePage.kt index fe7d6a6..4b01eaa 100644 --- a/site/src/jsMain/kotlin/ReferencePage.kt +++ b/site/src/jsMain/kotlin/ReferencePage.kt @@ -120,6 +120,12 @@ fun ReferencePage() { Div { Text("$kind ${d.name}$t") } d.doc?.summary?.let { Small({ classes("text-muted") }) { Text(it) } } } + is MiniTypeAliasDecl -> { + val params = if (d.typeParams.isEmpty()) "" else d.typeParams.joinToString(", ", "<", ">") + val target = DocLookupUtils.typeOf(d.target).ifEmpty { "Any" } + Div { Text("type ${d.name}$params = $target") } + d.doc?.summary?.let { Small({ classes("text-muted") }) { Text(it) } } + } is MiniClassDecl -> { Div { Text("class ${d.name}") } d.doc?.summary?.let { Small({ classes("text-muted") }) { Text(it) } } @@ -145,6 +151,12 @@ fun ReferencePage() { val staticStr = if (m.isStatic) "static " else "" Li { Text("${staticStr}${kindM} ${d.name}.${m.name}${ts}") } } + is MiniMemberTypeAliasDecl -> { + val params = if (m.typeParams.isEmpty()) "" else m.typeParams.joinToString(", ", "<", ">") + val target = DocLookupUtils.typeOf(m.target).ifEmpty { "Any" } + val staticStr = if (m.isStatic) "static " else "" + Li { Text("${staticStr}type ${d.name}.${m.name}$params = $target") } + } } } } diff --git a/site/src/jsMain/kotlin/TryLyngPage.kt b/site/src/jsMain/kotlin/TryLyngPage.kt index 2ab46a6..c3b47e0 100644 --- a/site/src/jsMain/kotlin/TryLyngPage.kt +++ b/site/src/jsMain/kotlin/TryLyngPage.kt @@ -20,7 +20,15 @@ import kotlinx.coroutines.launch import net.sergeych.lyng.LyngVersion import net.sergeych.lyng.Script import net.sergeych.lyng.ScriptError +import net.sergeych.lyng.highlight.TextRange +import net.sergeych.lyng.miniast.CompletionItem +import net.sergeych.lyng.tools.LyngDiagnostic +import net.sergeych.lyng.tools.LyngDiagnosticSeverity +import net.sergeych.lyng.tools.LyngSymbolInfo +import net.sergeych.lyng.tools.LyngSymbolTarget import net.sergeych.lyngweb.EditorWithOverlay +import net.sergeych.lyngweb.LyngWebTools +import org.jetbrains.compose.web.attributes.InputType import org.jetbrains.compose.web.dom.* @Composable @@ -52,6 +60,15 @@ fun TryLyngPage(route: String) { var output by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var extendedError by remember { mutableStateOf(null) } + var diagnostics by remember { mutableStateOf>(emptyList()) } + var completionItems by remember { mutableStateOf>(emptyList()) } + var completionOffset by remember { mutableStateOf(null) } + var docInfo by remember { mutableStateOf(null) } + var definitionTarget by remember { mutableStateOf(null) } + var usageRanges by remember { mutableStateOf>(emptyList()) } + var disasmSymbol by remember { mutableStateOf("") } + var disasmOutput by remember { mutableStateOf(null) } + var disasmError by remember { mutableStateOf(null) } fun runCode() { if (running) return @@ -59,6 +76,14 @@ fun TryLyngPage(route: String) { output = null error = null extendedError = null + completionItems = emptyList() + completionOffset = null + docInfo = null + definitionTarget = null + usageRanges = emptyList() + diagnostics = emptyList() + disasmOutput = null + disasmError = null scope.launch { // keep this outside try so we can show partial prints if evaluation fails val printed = StringBuilder() @@ -156,6 +181,22 @@ fun TryLyngPage(route: String) { runCode() } }, + onAnalysisReady = { analysis -> + diagnostics = analysis.diagnostics + }, + onCompletionRequested = { offset, items -> + completionOffset = offset + completionItems = items + }, + onDefinitionResolved = { _, target -> + definitionTarget = target + }, + onUsagesResolved = { _, ranges -> + usageRanges = ranges + }, + onDocRequested = { _, info -> + docInfo = info + }, // Keep current initial size but allow the editor to grow with content autoGrow = true ) @@ -218,10 +259,150 @@ fun TryLyngPage(route: String) { } } + // Language tools quick view + Div({ classes("card", "mb-3") }) { + Div({ classes("card-header", "d-flex", "align-items-center", "gap-2") }) { + I({ classes("bi", "bi-diagram-3") }) + Span({ classes("fw-semibold") }) { Text("Language tools") } + } + Div({ classes("card-body") }) { + Div({ classes("mb-3") }) { + Span({ classes("fw-semibold", "me-2") }) { Text("Diagnostics") } + if (diagnostics.isEmpty()) { + Span({ classes("text-muted") }) { Text("No errors or warnings.") } + } else { + Ul({ classes("mb-0") }) { + diagnostics.forEach { d -> + Li { + val sev = when (d.severity) { + LyngDiagnosticSeverity.Error -> "Error" + LyngDiagnosticSeverity.Warning -> "Warning" + } + val range = d.range?.let { " @${it.start}-${it.endExclusive}" } ?: "" + Text("$sev: ${d.message}$range") + } + } + } + } + } + + Div({ classes("mb-3") }) { + Span({ classes("fw-semibold", "me-2") }) { Text("Quick docs") } + if (docInfo == null) { + Span({ classes("text-muted") }) { Text("Press Ctrl+Q (or ⌘+Q) on a symbol.") } + } else { + val info = docInfo!! + Div({ classes("small") }) { + Text("${info.target.kind} ${info.target.name}") + info.signature?.let { sig -> + Br() + Code { Text(sig) } + } + info.doc?.summary?.let { doc -> + Br() + Text(doc) + } + } + } + } + + Div({ classes("mb-3") }) { + Span({ classes("fw-semibold", "me-2") }) { Text("Definition") } + if (definitionTarget == null) { + Span({ classes("text-muted") }) { Text("Press Ctrl+B (or ⌘+B) on a symbol.") } + } else { + val def = definitionTarget!! + Span({ classes("small") }) { + Text("${def.kind} ${def.name} @${def.range.start}-${def.range.endExclusive}") + } + } + } + + Div({ classes("mb-3") }) { + Span({ classes("fw-semibold", "me-2") }) { Text("Usages") } + if (usageRanges.isEmpty()) { + Span({ classes("text-muted") }) { Text("Press Ctrl+Shift+U (or ⌘+Shift+U) on a symbol.") } + } else { + Span({ classes("small") }) { Text("${usageRanges.size} usage(s) found.") } + } + } + + Div({ classes("mb-0") }) { + Span({ classes("fw-semibold", "me-2") }) { Text("Completions") } + if (completionItems.isEmpty()) { + Span({ classes("text-muted") }) { Text("Press Ctrl+Space (or ⌘+Space).") } + } else { + val shown = completionItems.take(8) + Span({ classes("text-muted", "small", "ms-1") }) { + completionOffset?.let { Text("@$it") } + } + Ul({ classes("mb-0") }) { + shown.forEach { item -> + Li { Text("${item.name} (${item.kind})") } + } + } + if (completionItems.size > shown.size) { + Span({ classes("text-muted", "small") }) { + Text("…and ${completionItems.size - shown.size} more") + } + } + } + } + } + } + + // Disassembly + Div({ classes("card", "mb-3") }) { + Div({ classes("card-header", "d-flex", "align-items-center", "gap-2") }) { + I({ classes("bi", "bi-braces") }) + Span({ classes("fw-semibold") }) { Text("Disassembly") } + } + Div({ classes("card-body") }) { + Div({ classes("d-flex", "gap-2", "align-items-center", "mb-2") }) { + Input(type = InputType.Text, attrs = { + classes("form-control") + attr("placeholder", "Symbol (e.g., MyClass.method or topLevelFun)") + value(disasmSymbol) + onInput { ev -> + disasmSymbol = ev.value + } + }) + Button(attrs = { + classes("btn", "btn-outline-primary") + if (disasmSymbol.isBlank()) attr("disabled", "disabled") + onClick { + it.preventDefault() + val symbol = disasmSymbol.trim() + if (symbol.isEmpty()) return@onClick + disasmOutput = null + disasmError = null + scope.launch { + try { + disasmOutput = LyngWebTools.disassembleSymbol(code, symbol) + } catch (t: Throwable) { + disasmError = t.message ?: t.toString() + } + } + } + }) { Text("Disassemble") } + } + if (disasmError != null) { + Div({ classes("alert", "alert-danger", "py-2", "mb-2") }) { Text(disasmError!!) } + } + if (disasmOutput != null) { + Pre({ classes("mb-0") }) { Code { Text(disasmOutput!!) } } + } else if (disasmError == null) { + Span({ classes("text-muted", "small") }) { + Text("Uses the bytecode compiler; not a dry run.") + } + } + } + } + // Tips P({ classes("text-muted", "small") }) { I({ classes("bi", "bi-info-circle", "me-1") }) - Text("Tip: press Ctrl+Enter (or ⌘+Enter on Mac) to run.") + Text("Tip: Ctrl+Enter runs, Ctrl+Space completes, Ctrl+B jumps to definition, Ctrl+Shift+U finds usages, Ctrl+Q shows docs.") } } }