fix #83 import-aware quickdocs

This commit is contained in:
Sergey Chernov 2025-12-06 22:25:21 +01:00
parent 40f11b6f29
commit bfffea7e69
8 changed files with 345 additions and 58 deletions

View File

@ -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<org.gradle.api.DefaultTask>("runIde") {
group = "intellij"
description = "Run IntelliJ IDEA with the Lyng plugin (:lyng-idea)"
dependsOn(":lyng-idea:runIde")
}

View File

@ -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 )

View File

@ -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")
}

View File

@ -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
}
}
}

View File

@ -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("<ide>", text)
val src = Source("<ide>", 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("<ide>", 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<MiniFunDecl>()) {
if (haveMini) for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) {
for (p in fn.params) {
val s = source.offsetOf(p.nameStart)
val e = (s + p.name.length).coerceAtMost(text.length)
@ -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<MiniFunDecl>().filter { it.name == ident }
if (matches.isNotEmpty()) {
@ -121,11 +138,19 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
// And classes
docs.filterIsInstance<MiniClassDecl>().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 "<div class='doc-title'>${htmlEscape(title)}</div>" + 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<String> {
val result = LinkedHashSet<String>()
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<String>): Map<String, MiniClassDecl> {
val map = LinkedHashMap<String, MiniClassDecl>()
for (mod in importedModules) {
val docs = BuiltinDocRegistry.docsForModule(mod)
docs.filterIsInstance<MiniClassDecl>().forEach { cls ->
// Prefer the first occurrence; allow later duplicates to be ignored
map.putIfAbsent(cls.name, cls)
}
}
return map
}
private fun resolveMemberWithInheritance(importedModules: List<String>, className: String, member: String): Pair<String, MiniMemberDecl>? {
val classes = aggregateClasses(importedModules)
fun dfs(name: String, visited: MutableSet<String>): Pair<String, MiniMemberDecl>? {
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<String>, member: String): Pair<String, MiniMemberDecl>? {
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
}

View File

@ -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
}
}

View File

@ -608,4 +608,4 @@ private fun buildStdlibDocs(): List<MiniDecl> {
return decls
}
// (Registration is triggered from BuiltinDocRegistry.init)
// (Registration for external modules is provided by their own libraries)

View File

@ -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<String> {
val raw = mini.imports.map { it.segments.joinToString(".") { s -> s.name } }
if (raw.isEmpty()) return emptyList()
val result = LinkedHashSet<String>()
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<String>): Map<String, MiniClassDecl> {
val map = LinkedHashMap<String, MiniClassDecl>()
for (mod in importedModules) {
val docs = BuiltinDocRegistry.docsForModule(mod)
docs.filterIsInstance<MiniClassDecl>().forEach { cls ->
if (!map.containsKey(cls.name)) map[cls.name] = cls
}
}
return map
}
fun resolveMemberWithInheritance(importedModules: List<String>, className: String, member: String): Pair<String, MiniMemberDecl>? {
val classes = aggregateClasses(importedModules)
fun dfs(name: String, visited: MutableSet<String>): Pair<String, MiniMemberDecl>? {
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<String>, member: String): Pair<String, MiniMemberDecl>? {
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>): 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()
}