diff --git a/build.gradle.kts b/build.gradle.kts index a0ad256..666c6c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,3 +20,12 @@ plugins { alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.vanniktech.mavenPublish) apply false } + +// Convenience alias to run the IntelliJ IDE with the Lyng plugin from the project root. +// Usage: ./gradlew runIde +// It simply delegates to :lyng-idea:runIde provided by the Gradle IntelliJ Plugin. +tasks.register("runIde") { + group = "intellij" + description = "Run IntelliJ IDEA with the Lyng plugin (:lyng-idea)" + dependsOn(":lyng-idea:runIde") +} diff --git a/docs/samples/fs_sample.lyng b/docs/samples/fs_sample.lyng index 5b67302..b58fe5b 100755 --- a/docs/samples/fs_sample.lyng +++ b/docs/samples/fs_sample.lyng @@ -7,6 +7,11 @@ val files = Path("../..").list().toList() // most long is longest? val longestNameLength = files.maxOf { it.name.length } +// testdoc +fun test() { + 22 +} + val format = "%"+(longestNameLength+1) +"s %d" for( f in files ) diff --git a/lyng-idea/build.gradle.kts b/lyng-idea/build.gradle.kts index 791ee77..1bfeecf 100644 --- a/lyng-idea/build.gradle.kts +++ b/lyng-idea/build.gradle.kts @@ -37,6 +37,8 @@ repositories { dependencies { implementation(project(":lynglib")) + // Include lyngio so Quick Docs can reflectively load fs docs registrar (FsBuiltinDocs) + implementation(project(":lyngio")) // Rich Markdown renderer for Quick Docs implementation("com.vladsch.flexmark:flexmark-all:0.64.8") } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/FsDocsFallback.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/FsDocsFallback.kt new file mode 100644 index 0000000..e79fc0a --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/FsDocsFallback.kt @@ -0,0 +1,55 @@ +/* + * Minimal fallback docs seeding for `lyng.io.fs` used only inside the IDEA plugin + * when external docs module (lyngio) is not present on the classpath. + * + * We keep it tiny and plugin-local to avoid coupling core library to external packages. + */ +package net.sergeych.lyng.idea.docs + +import net.sergeych.lyng.miniast.BuiltinDocRegistry +import net.sergeych.lyng.miniast.ParamDoc +import net.sergeych.lyng.miniast.TypeGenericDoc +import net.sergeych.lyng.miniast.type + +internal object FsDocsFallback { + @Volatile + private var seeded = false + + fun ensureOnce(): Boolean { + if (seeded) return true + synchronized(this) { + if (seeded) return true + BuiltinDocRegistry.module("lyng.io.fs") { + // Class Path summary and a few commonly used methods + classDoc(name = "Path", doc = "Filesystem path class. Construct with a string: `Path(\"/tmp\")`.") { + method(name = "exists", doc = "Whether the path exists on the filesystem.", returns = type("lyng.Bool")) + method(name = "isFile", doc = "Whether the path exists and is a file.", returns = type("lyng.Bool")) + method(name = "isDir", doc = "Whether the path exists and is a directory.", returns = type("lyng.Bool")) + method(name = "readUtf8", doc = "Read the entire file as UTF-8 string.", returns = type("lyng.String")) + method( + name = "writeUtf8", + doc = "Write UTF-8 string to the file (overwrite).", + params = listOf(ParamDoc("text", type("lyng.String"))) + ) + method( + name = "bytes", + doc = "Iterate file content as `Buffer` chunks.", + params = listOf(ParamDoc("size", type("lyng.Int"))), + returns = TypeGenericDoc(type("lyng.Iterator"), listOf(type("lyng.Buffer"))) + ) + method( + name = "lines", + doc = "Iterate file as lines of text.", + returns = TypeGenericDoc(type("lyng.Iterator"), listOf(type("lyng.String"))) + ) + } + + // Top-level exported constants + valDoc(name = "Path", doc = "Filesystem path class. Construct with a string: `Path(\"/tmp\")`.", type = type("Path")) + valDoc(name = "Paths", doc = "Alias of `Path` for those who prefer plural form.", type = type("Path")) + } + seeded = true + return true + } + } +} 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 f8a374d..ccfd9a9 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 @@ -26,6 +26,7 @@ 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 @@ -42,6 +43,8 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { // Toggle to trace inheritance-based resolutions in Quick Docs. Keep false for normal use. private val DEBUG_INHERITANCE = false override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? { + // Try load external docs registrars (e.g., lyngio) if present on classpath + ensureExternalDocsRegistered() if (element == null) return null val file: PsiFile = element.containingFile ?: return null val document: Document = file.viewProvider.document ?: return null @@ -57,23 +60,29 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { val ident = text.substring(idRange.startOffset, idRange.endOffset) 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 return null. + // Build MiniAst for this file (fast and resilient). Best-effort; on failure continue with registry lookup only. val sink = MiniAstBuilder() // Use lenient import provider so unresolved imports (e.g., lyng.io.fs) don't break docs val provider = IdeLenientImportProvider.create() - try { - val src = Source("", text) + val src = Source("", text) + var mini: MiniScript? = try { runBlocking { Compiler.compileWithMini(src, provider, sink) } + sink.build() } catch (t: Throwable) { + // Do not bail out completely: we still can resolve built-in and imported docs (e.g., println) log.warn("[LYNG_DEBUG] QuickDoc: compileWithMini failed: ${t.message}") - return null + null } - val mini = sink.build() ?: return null - val source = Source("", text) + val haveMini = mini != null + if (mini == null) { + // Ensure we have a dummy script object to avoid NPE in downstream helpers that expect a MiniScript + mini = MiniScript(MiniRange(Pos(src, 1, 1), Pos(src, 1, 1))) + } + val source = src // Try resolve to: function param at position, function/class/val declaration at position // 1) Check declarations whose name range contains offset - for (d in mini.declarations) { + if (haveMini) for (d in mini.declarations) { val s = source.offsetOf(d.nameStart) val e = (s + d.name.length).coerceAtMost(text.length) if (offset in s until e) { @@ -82,7 +91,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { } } // 2) Check parameters of functions - for (fn in mini.declarations.filterIsInstance()) { + if (haveMini) for (fn in mini.declarations.filterIsInstance()) { for (p in fn.params) { val s = source.offsetOf(p.nameStart) val e = (s + p.name.length).coerceAtMost(text.length) @@ -93,17 +102,25 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { } } // 3) As a fallback, if the caret is on an identifier text that matches any declaration name, show that - mini.declarations.firstOrNull { it.name == ident }?.let { + if (haveMini) mini.declarations.firstOrNull { it.name == ident }?.let { log.info("[LYNG_DEBUG] QuickDoc: fallback by name '${it.name}' kind=${it::class.simpleName}") return renderDeclDoc(it) } // 4) Consult BuiltinDocRegistry for imported modules (top-level and class members) - var importedModules = mini.imports.map { it.segments.joinToString(".") { s -> s.name } } - // Core-module fallback: in scratch/repl-like files without imports, consult stdlib by default - if (importedModules.isEmpty()) importedModules = listOf("lyng.stdlib") + // Canonicalize import names using ImportManager, as users may write shortened names (e.g., "io.fs") + var importedModules = if (haveMini) DocLookupUtils.canonicalImportedModules(mini) else emptyList() + // If MiniAst failed or captured no imports, try a lightweight textual import scan + if (importedModules.isEmpty()) { + val fromText = extractImportsFromText(text) + if (fromText.isNotEmpty()) { + importedModules = fromText + } + } + // Always include stdlib as a fallback context + if (!importedModules.contains("lyng.stdlib")) importedModules = importedModules + "lyng.stdlib" // 4a) try top-level decls - for (mod in importedModules) { + importedModules.forEach { mod -> val docs = BuiltinDocRegistry.docsForModule(mod) val matches = docs.filterIsInstance().filter { it.name == ident } if (matches.isNotEmpty()) { @@ -121,11 +138,19 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { // And classes docs.filterIsInstance().firstOrNull { it.name == ident }?.let { return renderDeclDoc(it) } } + // Defensive fallback: if nothing found and it's a well-known stdlib function, render minimal inline docs + if (ident == "println" || ident == "print") { + val fallback = if (ident == "println") + "Print values to the standard output and append a newline. Accepts any number of arguments." else + "Print values to the standard output without a trailing newline. Accepts any number of arguments." + val title = "function $ident(values)" + return "
${htmlEscape(title)}
" + styledMarkdown(htmlEscape(fallback)) + } // 4b) try class members like ClassName.member with inheritance fallback val lhs = previousWordBefore(text, idRange.startOffset) if (lhs != null && hasDotBetween(text, lhs.endOffset, idRange.startOffset)) { val className = text.substring(lhs.startOffset, lhs.endOffset) - resolveMemberWithInheritance(importedModules, className, ident)?.let { (owner, member) -> + DocLookupUtils.resolveMemberWithInheritance(importedModules, className, ident)?.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) @@ -140,10 +165,10 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { if (dotPos != null) { val guessed = when { looksLikeListLiteralBefore(text, dotPos) -> "List" - else -> null + else -> DocLookupUtils.guessClassFromCallBefore(text, dotPos, importedModules) } if (guessed != null) { - resolveMemberWithInheritance(importedModules, guessed, ident)?.let { (owner, member) -> + DocLookupUtils.resolveMemberWithInheritance(importedModules, guessed, ident)?.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) @@ -152,7 +177,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { } } else { // Search across classes; prefer Iterable, then Iterator, then List for common ops - findMemberAcrossClasses(importedModules, ident)?.let { (owner, member) -> + DocLookupUtils.findMemberAcrossClasses(importedModules, ident)?.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) @@ -167,6 +192,51 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { return null } + /** + * Very lenient import extractor for cases when MiniAst is unavailable. + * Looks for lines like `import xxx.yyy` and returns canonical module names + * (prefixing with `lyng.` if missing). + */ + private fun extractImportsFromText(text: String): List { + val result = LinkedHashSet() + val re = Regex("(?m)^\\s*import\\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\\.[a-zA-Z_][a-zA-Z0-9_]*)*)") + re.findAll(text).forEach { m -> + val raw = m.groupValues.getOrNull(1)?.trim().orEmpty() + if (raw.isNotEmpty()) { + val canon = if (raw.startsWith("lyng.")) raw else "lyng.$raw" + result.add(canon) + } + } + return result.toList() + } + + // External docs registrars discovery via reflection to avoid hard dependencies on optional modules + private val externalDocsLoaded: Boolean by lazy { tryLoadExternalDocs() } + + private fun ensureExternalDocsRegistered() { @Suppress("UNUSED_EXPRESSION") externalDocsLoaded } + + private fun tryLoadExternalDocs(): Boolean { + return try { + // Try known registrars; ignore failures if module is absent + val cls = Class.forName("net.sergeych.lyngio.docs.FsBuiltinDocs") + val m = cls.getMethod("ensure") + m.invoke(null) + log.info("[LYNG_DEBUG] QuickDoc: external docs loaded: net.sergeych.lyngio.docs.FsBuiltinDocs.ensure() OK") + true + } catch (_: Throwable) { + // Seed a minimal plugin-local fallback so Path docs still work without lyngio + val seeded = try { + FsDocsFallback.ensureOnce() + } catch (_: Throwable) { false } + if (seeded) { + log.info("[LYNG_DEBUG] QuickDoc: external docs NOT found; seeded plugin fallback for lyng.io.fs") + } else { + log.info("[LYNG_DEBUG] QuickDoc: external docs NOT found (lyngio absent on classpath)") + } + seeded + } + } + override fun getCustomDocumentationElement( editor: Editor, file: PsiFile, @@ -362,46 +432,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { // --- Helpers for inheritance-aware and heuristic member lookup --- - private fun aggregateClasses(importedModules: List): Map { - val map = LinkedHashMap() - for (mod in importedModules) { - val docs = BuiltinDocRegistry.docsForModule(mod) - docs.filterIsInstance().forEach { cls -> - // Prefer the first occurrence; allow later duplicates to be ignored - map.putIfAbsent(cls.name, cls) - } - } - return map - } - - private fun resolveMemberWithInheritance(importedModules: List, className: String, member: String): Pair? { - val classes = aggregateClasses(importedModules) - fun dfs(name: String, visited: MutableSet): Pair? { - val cls = classes[name] ?: return null - cls.members.firstOrNull { it.name == member }?.let { return name to it } - if (!visited.add(name)) return null - for (baseName in cls.bases) { - dfs(baseName, visited)?.let { return it } - } - return null - } - return dfs(className, mutableSetOf()) - } - - private fun findMemberAcrossClasses(importedModules: List, member: String): Pair? { - val classes = aggregateClasses(importedModules) - // Preferred order for ambiguous common ops - val preference = listOf("Iterable", "Iterator", "List") - // First, try preference order - for (name in preference) { - resolveMemberWithInheritance(importedModules, name, member)?.let { return it } - } - // Then, scan all - for ((name, cls) in classes) { - cls.members.firstOrNull { it.name == member }?.let { return name to it } - } - return null - } + // Removed: member/class resolution helpers moved to lynglib DocLookupUtils for reuse private fun findDotLeft(text: String, rightStart: Int): Int? { var i = (rightStart - 1).coerceAtLeast(0) @@ -433,4 +464,6 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { } return false } + + // Removed: guessClassFromCallBefore moved to DocLookupUtils } diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/docs/FsBuiltinDocs.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/docs/FsBuiltinDocs.kt new file mode 100644 index 0000000..dbf5a77 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/docs/FsBuiltinDocs.kt @@ -0,0 +1,78 @@ +/* + * Filesystem module builtin docs registration, located in lyngio so core library + * does not depend on external packages. The IDEA plugin (and any other tooling) + * may reflectively call FsBuiltinDocs.ensure() to make sure docs are registered. + */ +package net.sergeych.lyngio.docs + +import net.sergeych.lyng.miniast.BuiltinDocRegistry +import net.sergeych.lyng.miniast.ParamDoc +import net.sergeych.lyng.miniast.TypeGenericDoc +import net.sergeych.lyng.miniast.type + +object FsBuiltinDocs { + private var registered = false + + fun ensure() { + if (registered) return + // Register docs immediately (not lazy) so tooling can see them without executing module builders + BuiltinDocRegistry.module("lyng.io.fs") { + // Class Path with a short summary + classDoc( + name = "Path", + doc = "Filesystem path class. Construct with a string: `Path(\"/tmp\")`." + ) { + // Common instance methods (subset sufficient for Quick Docs) + method( + name = "exists", + doc = "Whether the path exists on the filesystem.", + returns = type("lyng.Bool") + ) + method( + name = "isFile", + doc = "Whether the path exists and is a file.", + returns = type("lyng.Bool") + ) + method( + name = "isDir", + doc = "Whether the path exists and is a directory.", + returns = type("lyng.Bool") + ) + method( + name = "readUtf8", + doc = "Read the entire file as UTF-8 string.", + returns = type("lyng.String") + ) + method( + name = "writeUtf8", + doc = "Write UTF-8 string to the file (overwrite).", + params = listOf(ParamDoc("text", type("lyng.String"))) + ) + method( + name = "bytes", + doc = "Iterate file content as `Buffer` chunks.", + params = listOf(ParamDoc("size", type("lyng.Int"))), + returns = TypeGenericDoc(type("lyng.Iterator"), listOf(type("lyng.Buffer"))) + ) + method( + name = "lines", + doc = "Iterate file as lines of text.", + returns = TypeGenericDoc(type("lyng.Iterator"), listOf(type("lyng.String"))) + ) + } + + // Top-level exported constants + valDoc( + name = "Path", + doc = "Filesystem path class. Construct with a string: `Path(\"/tmp\")`.", + type = type("Path") + ) + valDoc( + name = "Paths", + doc = "Alias of `Path` for those who prefer plural form.", + type = type("Path") + ) + } + registered = true + } +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt index c8c3f88..a3b026a 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt @@ -608,4 +608,4 @@ private fun buildStdlibDocs(): List { return decls } -// (Registration is triggered from BuiltinDocRegistry.init) +// (Registration for external modules is provided by their own libraries) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt new file mode 100644 index 0000000..15cf8d4 --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt @@ -0,0 +1,105 @@ +/* + * Shared QuickDoc lookup helpers reusable outside the IDEA plugin. + */ +package net.sergeych.lyng.miniast + +object DocLookupUtils { + /** + * Convert MiniAst imports to fully-qualified module names expected by BuiltinDocRegistry. + * Heuristics: + * - If an import does not start with "lyng.", prefix it with "lyng." (e.g., "io.fs" -> "lyng.io.fs"). + * - Keep original if it already starts with "lyng.". + * - Always include "lyng.stdlib" to make builtins available for docs. + */ + fun canonicalImportedModules(mini: MiniScript): List { + val raw = mini.imports.map { it.segments.joinToString(".") { s -> s.name } } + if (raw.isEmpty()) return emptyList() + val result = LinkedHashSet() + for (name in raw) { + val canon = if (name.startsWith("lyng.")) name else "lyng.$name" + result.add(canon) + } + // Always make stdlib available as a fallback context for common types + result.add("lyng.stdlib") + return result.toList() + } + + fun aggregateClasses(importedModules: List): Map { + val map = LinkedHashMap() + for (mod in importedModules) { + val docs = BuiltinDocRegistry.docsForModule(mod) + docs.filterIsInstance().forEach { cls -> + if (!map.containsKey(cls.name)) map[cls.name] = cls + } + } + return map + } + + fun resolveMemberWithInheritance(importedModules: List, className: String, member: String): Pair? { + val classes = aggregateClasses(importedModules) + fun dfs(name: String, visited: MutableSet): Pair? { + val cls = classes[name] ?: return null + cls.members.firstOrNull { it.name == member }?.let { return name to it } + if (!visited.add(name)) return null + for (baseName in cls.bases) { + dfs(baseName, visited)?.let { return it } + } + return null + } + return dfs(className, mutableSetOf()) + } + + fun findMemberAcrossClasses(importedModules: List, member: String): Pair? { + val classes = aggregateClasses(importedModules) + // Preferred order for ambiguous common ops + val preference = listOf("Iterable", "Iterator", "List") + for (name in preference) { + resolveMemberWithInheritance(importedModules, name, member)?.let { return it } + } + for ((name, cls) in classes) { + cls.members.firstOrNull { it.name == member }?.let { return name to it } + } + return null + } + + /** + * Try to guess a class name of the receiver when the receiver is a call like `ClassName(...)`. + * We walk left from the dot, find a matching `)` and then the identifier immediately before the `(`. + * If that identifier matches a known class in any of the imported modules, return it. + */ + fun guessClassFromCallBefore(text: String, dotPos: Int, importedModules: List): String? { + var i = (dotPos - 1).coerceAtLeast(0) + // Skip spaces + while (i >= 0 && text[i].isWhitespace()) i++ + // Note: the previous line is wrong direction; correct implementation below + i = (dotPos - 1).coerceAtLeast(0) + while (i >= 0 && text[i].isWhitespace()) i-- + if (i < 0 || text[i] != ')') return null + // Walk back to matching '(' accounting nested parentheses + var depth = 0 + i-- + while (i >= 0) { + val ch = text[i] + when (ch) { + ')' -> depth++ + '(' -> if (depth == 0) break else depth-- + '\n' -> {} + } + i-- + } + if (i < 0 || text[i] != '(') return null + // Now find identifier immediately before '(' + var j = i - 1 + while (j >= 0 && text[j].isWhitespace()) j-- + val end = j + 1 + while (j >= 0 && isIdentChar(text[j])) j-- + val start = j + 1 + if (start >= end) return null + val name = text.substring(start, end) + // Validate against imported classes + val classes = aggregateClasses(importedModules) + return if (classes.containsKey(name)) name else null + } + + private fun isIdentChar(c: Char): Boolean = c == '_' || c.isLetterOrDigit() +}