From 8e10540257afee45b3fa281969bc35fea0e29b10 Mon Sep 17 00:00:00 2001 From: sergeych Date: Mon, 23 Feb 2026 15:04:25 +0300 Subject: [PATCH] better processing of lyng.d. files --- build.gradle.kts | 24 ++++ docs/idea_plugin.md | 5 +- docs/lyng_d_files.md | 116 +++++++++++++++ docs/samples/definitions.lyng.d | 65 +++++++++ .../lyng/idea/actions/RunLyngScriptAction.kt | 48 +------ .../idea/docs/LyngDocumentationProvider.kt | 96 ++++++++++++- .../lyng/idea/navigation/LyngPsiReference.kt | 94 +++++++++--- .../sergeych/lyng/idea/util/LyngAstManager.kt | 134 ++++++++++++++++-- .../definitions/LyngDefinitionFilesTest.kt | 127 +++++++++++++++++ .../lyng/miniast/CompletionEngineLight.kt | 9 +- .../sergeych/lyng/miniast/DocLookupUtils.kt | 1 + 11 files changed, 643 insertions(+), 76 deletions(-) create mode 100644 docs/lyng_d_files.md create mode 100644 docs/samples/definitions.lyng.d create mode 100644 lyng-idea/src/test/kotlin/net/sergeych/lyng/idea/definitions/LyngDefinitionFilesTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 57a7286..1842e03 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,3 +35,27 @@ tasks.register("generateDocs") { description = "Generates a single-file documentation HTML using bin/generate_docs.sh" commandLine("./bin/generate_docs.sh") } + +// Sample generator task for .lyng.d definition files (not wired into build). +// Usage: ./gradlew generateLyngDefsSample +tasks.register("generateLyngDefsSample") { + group = "lyng" + description = "Generate a sample .lyng.d file under build/generated/lyng/defs" + outputs.dir(layout.buildDirectory.dir("generated/lyng/defs")) + doLast { + val outDir = layout.buildDirectory.dir("generated/lyng/defs").get().asFile + outDir.mkdirs() + val outFile = outDir.resolve("sample.lyng.d") + outFile.writeText( + """ + /** Generated API */ + extern fun ping(): Int + + /** Generated class */ + class Generated(val name: String) { + fun greet(): String = "hi " + name + } + """.trimIndent() + ) + } +} diff --git a/docs/idea_plugin.md b/docs/idea_plugin.md index 72ff19a..2593d44 100644 --- a/docs/idea_plugin.md +++ b/docs/idea_plugin.md @@ -9,9 +9,12 @@ should be compatible with other IDEA flavors, notably [OpenIDE](https://openide. - reformat code (indents, spaces) - reformat on paste - smart enter key +- `.lyng.d` definition files (merged into analysis for completion, navigation, Quick Docs, and error checking) Features are configurable via the plugin settings page, in system settings. +See `docs/lyng_d_files.md` for `.lyng.d` syntax and examples. + > Recommended for IntelliJ-based IDEs: While IntelliJ can import TextMate bundles > (Settings/Preferences → Editor → TextMate Bundles), the native Lyng plugin provides > better support (formatting, smart enter, background analysis, etc.). Prefer installing @@ -26,4 +29,4 @@ Features are configurable via the plugin settings page, in system settings. ### [Download plugin v0.0.2-SNAPSHOT](https://lynglang.com/distributables/lyng-idea-0.0.2-SNAPSHOT.zip) -Your ideas and bugreports are welcome on the [project gitea page](https://gitea.sergeych.net/SergeychWorks/lyng/issues) \ No newline at end of file +Your ideas and bugreports are welcome on the [project gitea page](https://gitea.sergeych.net/SergeychWorks/lyng/issues) diff --git a/docs/lyng_d_files.md b/docs/lyng_d_files.md new file mode 100644 index 0000000..aa94e4f --- /dev/null +++ b/docs/lyng_d_files.md @@ -0,0 +1,116 @@ +# `.lyng.d` Definition Files + +`.lyng.d` files declare Lyng symbols for tooling without shipping runtime implementations. The IntelliJ IDEA plugin merges +all `*.lyng.d` files from the current directory and its parent directories into the active file’s analysis, enabling: + +- completion +- navigation +- error checking for declared symbols +- Quick Docs for declarations defined in `.lyng.d` + +Place `*.lyng.d` files next to the code they describe (or in a parent folder). The plugin will pick them up automatically. + +## Writing `.lyng.d` Files + +You can declare any language-level symbol in a `.lyng.d` file. Use doc comments before declarations to make Quick Docs +work in the IDE. The doc parser accepts standard comments (`/** ... */` or `// ...`) and supports tags like `@param`. + +### Full Example + +```lyng +/** Library entry point */ +extern fun connect(url: String, timeoutMs: Int = 5000): Client + +/** Type alias with generics */ +type NameMap = Map + +/** Multiple inheritance via interfaces */ +interface A { abstract fun a(): Int } +interface B { abstract fun b(): Int } + +/** A concrete class implementing both */ +class Multi(name: String) : A, B { + /** Public field */ + val id: Int = 0 + + /** Mutable property with accessors */ + var size: Int + get() = 0 + set(v) { } + + /** Instance method */ + fun a(): Int = 1 + fun b(): Int = 2 +} + +/** Nullable and dynamic types */ +extern val dynValue: dynamic +extern var dynVar: dynamic? + +/** Delegated property */ +class LazyBox(val create) { + fun getValue(thisRef, name) = create() +} + +val cached by LazyBox { 42 } + +/** Delegated function */ +object RpcDelegate { + fun invoke(thisRef, name, args...) = Unset +} + +fun remoteCall by RpcDelegate + +/** Singleton object */ +object Settings { + val version: String = "1.0" +} + +/** Class with documented members */ +class Client { + /** Returns a greeting. */ + fun greet(name: String): String = "hi " + name +} +``` + +See a runnable sample file in `docs/samples/definitions.lyng.d`. + +Notes: +- Use real bodies if the declaration is not `extern` or `abstract`. +- If you need purely declarative stubs, prefer `extern` members (see `embedding.md`). + +## Doc Comment Format + +Doc comments are picked up when they immediately precede a declaration. + +```lyng +/** + * A sample function. + * @param name user name + * @return greeting string + */ +fun greet(name: String): String = "hi " + name +``` + +## Generating `.lyng.d` Files + +You can generate `.lyng.d` as part of your build. A common approach is to write a Gradle task that emits a file from a +template or a Kotlin data model. + +Example (pseudo-code): + +```kotlin +tasks.register("generateLyngDefs") { + doLast { + val out = file("src/main/lyng/api.lyng.d") + out.writeText( + """ + /** Generated API */ + fun ping(): Int + """.trimIndent() + ) + } +} +``` + +Place the generated file in your source tree, and the IDE will load it automatically. diff --git a/docs/samples/definitions.lyng.d b/docs/samples/definitions.lyng.d new file mode 100644 index 0000000..c196607 --- /dev/null +++ b/docs/samples/definitions.lyng.d @@ -0,0 +1,65 @@ +/** + * Sample .lyng.d file for IDE support. + * Demonstrates declarations and doc comments. + */ + +/** Simple function with default and named parameters. */ +extern fun connect(url: String, timeoutMs: Int = 5000): Client + +/** Type alias with generics. */ +type NameMap = Map + +/** Multiple inheritance via interfaces. */ +interface A { abstract fun a(): Int } +interface B { abstract fun b(): Int } + +/** A concrete class implementing both. */ +class Multi(name: String) : A, B { + /** Public field. */ + val id: Int = 0 + + /** Mutable property with accessors. */ + var size: Int + get() = 0 + set(v) { } + + /** Instance method. */ + fun a(): Int = 1 + fun b(): Int = 2 +} + +/** Nullable and dynamic types. */ +extern val dynValue: dynamic +extern var dynVar: dynamic? + +/** Delegated property provider. */ +class LazyBox(val create) { + fun getValue(thisRef, name) = create() +} + +/** Delegated property using provider. */ +val cached by LazyBox { 42 } + +/** Delegated function. */ +object RpcDelegate { + fun invoke(thisRef, name, args...) = Unset +} + +/** Remote function proxy. */ +fun remoteCall by RpcDelegate + +/** Singleton object. */ +object Settings { + /** Version string. */ + val version: String = "1.0" +} + +/** + * Client API entry. + * @param name user name + * @return greeting string + */ +class Client { + /** Returns a greeting. */ + fun greet(name: String): String = "hi " + name +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/actions/RunLyngScriptAction.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/actions/RunLyngScriptAction.kt index 139c519..3cb1ac5 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/actions/RunLyngScriptAction.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/actions/RunLyngScriptAction.kt @@ -35,13 +35,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import net.sergeych.lyng.ExecutionError -import net.sergeych.lyng.Script -import net.sergeych.lyng.Source -import net.sergeych.lyng.requireScope import net.sergeych.lyng.idea.LyngIcons -import net.sergeych.lyng.obj.ObjVoid -import net.sergeych.lyng.obj.getLyngExceptionMessageWithStackTrace class RunLyngScriptAction : AnAction(LyngIcons.FILE) { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @@ -59,7 +53,9 @@ class RunLyngScriptAction : AnAction(LyngIcons.FILE) { val isLyng = psiFile?.name?.endsWith(".lyng") == true e.presentation.isEnabledAndVisible = isLyng if (isLyng) { - e.presentation.text = "Run '${psiFile.name}'" + e.presentation.isEnabled = false + e.presentation.text = "Run '${psiFile.name}' (disabled)" + e.presentation.description = "Running scripts from the IDE is disabled; use the CLI." } else { e.presentation.text = "Run Lyng Script" } @@ -68,7 +64,6 @@ class RunLyngScriptAction : AnAction(LyngIcons.FILE) { override fun actionPerformed(e: AnActionEvent) { val project = e.project ?: return val psiFile = getPsiFile(e) ?: return - val text = psiFile.text val fileName = psiFile.name val (console, toolWindow) = getConsoleAndToolWindow(project) @@ -76,40 +71,9 @@ class RunLyngScriptAction : AnAction(LyngIcons.FILE) { toolWindow.show { scope.launch { - try { - val lyngScope = Script.newScope() - lyngScope.addFn("print") { - val sb = StringBuilder() - for ((i, arg) in args.list.withIndex()) { - if (i > 0) sb.append(" ") - sb.append(arg.toString(requireScope()).value) - } - console.print(sb.toString(), ConsoleViewContentType.NORMAL_OUTPUT) - ObjVoid - } - lyngScope.addFn("println") { - val sb = StringBuilder() - for ((i, arg) in args.list.withIndex()) { - if (i > 0) sb.append(" ") - sb.append(arg.toString(requireScope()).value) - } - console.print(sb.toString() + "\n", ConsoleViewContentType.NORMAL_OUTPUT) - ObjVoid - } - - console.print("--- Running $fileName ---\n", ConsoleViewContentType.SYSTEM_OUTPUT) - val result = lyngScope.eval(Source(fileName, text)) - console.print("\n--- Finished with result: ${result.inspect(lyngScope)} ---\n", ConsoleViewContentType.SYSTEM_OUTPUT) - } catch (t: Throwable) { - console.print("\n--- Error ---\n", ConsoleViewContentType.ERROR_OUTPUT) - if( t is ExecutionError ) { - val m = t.errorObject.getLyngExceptionMessageWithStackTrace() - console.print(m, ConsoleViewContentType.ERROR_OUTPUT) - } - else - console.print(t.message ?: t.toString(), ConsoleViewContentType.ERROR_OUTPUT) - console.print("\n", ConsoleViewContentType.ERROR_OUTPUT) - } + console.print("--- Run is disabled ---\n", ConsoleViewContentType.SYSTEM_OUTPUT) + console.print("Lyng now runs in bytecode-only mode; the IDE no longer evaluates scripts.\n", ConsoleViewContentType.NORMAL_OUTPUT) + console.print("Use the CLI to run scripts, e.g. `lyng run $fileName`.\n", ConsoleViewContentType.NORMAL_OUTPUT) } } } 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 03fc1a8..fc77346 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt @@ -24,6 +24,7 @@ import com.intellij.openapi.editor.Editor import com.intellij.openapi.util.TextRange import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile +import kotlinx.coroutines.runBlocking import net.sergeych.lyng.highlight.offsetOf import net.sergeych.lyng.idea.LyngLanguage import net.sergeych.lyng.idea.util.LyngAstManager @@ -75,7 +76,51 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { // Single-source quick doc lookup LyngLanguageTools.docAt(analysis, offset)?.let { info -> - renderDocFromInfo(info)?.let { return it } + val enriched = if (info.doc == null) { + findDocInDeclarationFiles(file, info.target.containerName, info.target.name) + ?.let { info.copy(doc = it) } ?: info + } else { + info + } + renderDocFromInfo(enriched)?.let { return it } + } + + // Fallback: resolve references against merged MiniAst (including .lyng.d) when binder cannot + run { + val dotPos = DocLookupUtils.findDotLeft(text, idRange.startOffset) + if (dotPos != null) { + val receiverClass = DocLookupUtils.guessReceiverClassViaMini(mini, text, dotPos, imported, analysis.binding) + ?: DocLookupUtils.guessReceiverClass(text, dotPos, imported, mini) + if (receiverClass != null) { + val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, ident, mini) + if (resolved != null) { + val owner = resolved.first + val member = resolved.second + val withDoc = if (member.doc == null) { + findDocInDeclarationFiles(file, owner, member.name)?.let { doc -> + when (member) { + is MiniMemberFunDecl -> member.copy(doc = doc) + is MiniMemberValDecl -> member.copy(doc = doc) + is MiniMemberTypeAliasDecl -> member.copy(doc = doc) + else -> member + } + } ?: member + } else { + member + } + return when (withDoc) { + is MiniMemberFunDecl -> renderMemberFunDoc(owner, withDoc) + is MiniMemberValDecl -> renderMemberValDoc(owner, withDoc) + is MiniMemberTypeAliasDecl -> renderMemberTypeAliasDoc(owner, withDoc) + else -> null + } + } + } + } else { + mini.declarations.firstOrNull { it.name == ident }?.let { decl -> + return renderDeclDoc(decl, text, mini, imported) + } + } } // Try resolve to: function param at position, function/class/val declaration at position @@ -570,6 +615,55 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { return sb.toString() } + private fun findDocInDeclarationFiles(file: PsiFile, container: String?, name: String): MiniDoc? { + val declFiles = LyngAstManager.getDeclarationFiles(file) + if (declFiles.isEmpty()) return null + + fun findInMini(mini: MiniScript): MiniDoc? { + if (container == null) { + mini.declarations.firstOrNull { it.name == name }?.let { return it.doc } + return null + } + val cls = mini.declarations.filterIsInstance().firstOrNull { it.name == container } ?: return null + cls.members.firstOrNull { it.name == name }?.let { return it.doc } + cls.ctorFields.firstOrNull { it.name == name }?.let { return null } + cls.classFields.firstOrNull { it.name == name }?.let { return null } + return null + } + + for (df in declFiles) { + val mini = LyngAstManager.getMiniAst(df) + ?: run { + try { + val res = runBlocking { + LyngLanguageTools.analyze(df.text, df.name) + } + res.mini + } catch (_: Throwable) { + null + } + } + if (mini != null) { + val doc = findInMini(mini) + if (doc != null) return doc + } + // Text fallback: parse preceding doc comment for the symbol + val parsed = parseDocFromText(df.text, name) + if (parsed != null) return parsed + } + return null + } + + private fun parseDocFromText(text: String, name: String): MiniDoc? { + if (text.isBlank()) return null + val pattern = Regex("/\\*\\*([\\s\\S]*?)\\*/\\s*(?:public|private|protected|static|abstract|extern|open|closed|override\\s+)*\\s*(?:fun|val|var|class|interface|enum|type)\\s+$name\\b") + val m = pattern.find(text) ?: return null + val raw = m.groupValues.getOrNull(1)?.trim() ?: return null + if (raw.isBlank()) return null + val src = net.sergeych.lyng.Source("", raw) + return MiniDoc.parse(MiniRange(src.startPos, src.startPos), raw.lines()) + } + private fun renderParamDoc(fn: MiniFunDecl, p: MiniParam): String { val title = "parameter ${p.name}${typeOf(p.type)} in ${fn.name}${signatureOf(fn)}" val sb = StringBuilder() 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 33b872c..62f9d9d 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 @@ -20,12 +20,18 @@ package net.sergeych.lyng.idea.navigation import com.intellij.openapi.project.Project import com.intellij.openapi.util.TextRange import com.intellij.psi.* +import com.intellij.psi.search.FileTypeIndex import com.intellij.psi.search.FilenameIndex import com.intellij.psi.search.GlobalSearchScope +import kotlinx.coroutines.runBlocking import net.sergeych.lyng.highlight.offsetOf +import net.sergeych.lyng.idea.LyngFileType import net.sergeych.lyng.idea.util.LyngAstManager import net.sergeych.lyng.idea.util.TextCtx import net.sergeych.lyng.miniast.* +import net.sergeych.lyng.tools.IdeLenientImportProvider +import net.sergeych.lyng.tools.LyngAnalysisRequest +import net.sergeych.lyng.tools.LyngLanguageTools class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase(element, TextRange(0, element.textLength)) { @@ -58,7 +64,7 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase? = null): List { val results = mutableListOf() - val files = FilenameIndex.getAllFilesByExt(project, "lyng", GlobalSearchScope.projectScope(project)) val psiManager = PsiManager.getInstance(project) - for (vFile in files) { - val file = psiManager.findFile(vFile) ?: continue + for (file in collectLyngFiles(project)) { // Filter by package if requested if (allowedPackages != null) { val pkg = getPackageName(file) - if (pkg == null || pkg !in allowedPackages) continue + if (pkg == null) { + if (!file.name.endsWith(".lyng.d")) continue + } else if (pkg !in allowedPackages) continue } - val mini = LyngAstManager.getMiniAst(file) ?: continue + val mini = loadMini(file) ?: continue val src = mini.range.start.source fun addIfMatch(dName: String, nameStart: net.sergeych.lyng.Pos, dKind: String) { @@ -197,6 +216,7 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase "Function" @@ -216,6 +236,7 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase "Function" is net.sergeych.lyng.miniast.MiniMemberValDecl -> if (m.mutable) "Variable" else "Value" @@ -229,5 +250,42 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase { + val scope = GlobalSearchScope.projectScope(project) + val psiManager = PsiManager.getInstance(project) + val out = LinkedHashSet() + + val lyngFiles = FilenameIndex.getAllFilesByExt(project, "lyng", scope) + for (vFile in lyngFiles) { + psiManager.findFile(vFile)?.let { out.add(it) } + } + + // Include declaration files (*.lyng.d) which are indexed as extension "d". + val dFiles = FilenameIndex.getAllFilesByExt(project, "d", scope) + for (vFile in dFiles) { + if (!vFile.name.endsWith(".lyng.d")) continue + psiManager.findFile(vFile)?.let { out.add(it) } + } + + return out.toList() + } + + private fun loadMini(file: PsiFile): MiniScript? { + LyngAstManager.getMiniAst(file)?.let { return it } + return try { + val provider = IdeLenientImportProvider.create() + runBlocking { + LyngLanguageTools.analyze( + LyngAnalysisRequest(text = file.text, fileName = file.name, importProvider = provider) + ) + }.mini + } catch (_: Throwable) { + null + } + } + + private fun isLocalDecl(mini: MiniScript, decl: MiniDecl): Boolean = + decl.range.start.source == mini.range.start.source + override fun getVariants(): Array = emptyArray() } 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 e6fa649..b001b9b 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 @@ -21,14 +21,22 @@ import com.intellij.openapi.application.runReadAction import com.intellij.openapi.util.Key import com.intellij.psi.PsiFile import com.intellij.psi.PsiManager +import com.intellij.psi.search.FileTypeIndex +import com.intellij.psi.search.FilenameIndex +import com.intellij.psi.search.GlobalSearchScope import kotlinx.coroutines.runBlocking import net.sergeych.lyng.binding.BindingSnapshot +import net.sergeych.lyng.miniast.BuiltinDocRegistry import net.sergeych.lyng.miniast.DocLookupUtils +import net.sergeych.lyng.miniast.MiniEnumDecl +import net.sergeych.lyng.miniast.MiniRange import net.sergeych.lyng.miniast.MiniScript import net.sergeych.lyng.tools.IdeLenientImportProvider import net.sergeych.lyng.tools.LyngAnalysisRequest import net.sergeych.lyng.tools.LyngAnalysisResult +import net.sergeych.lyng.tools.LyngDiagnostic import net.sergeych.lyng.tools.LyngLanguageTools +import net.sergeych.lyng.idea.LyngFileType object LyngAstManager { private val MINI_KEY = Key.create("lyng.mini.cache") @@ -52,22 +60,65 @@ object LyngAstManager { private fun collectDeclarationFiles(file: PsiFile): List = runReadAction { val psiManager = PsiManager.getInstance(file.project) - var current = file.virtualFile?.parent val seen = mutableSetOf() val result = mutableListOf() - while (current != null) { - for (child in current.children) { - if (child.name.endsWith(".lyng.d") && child != file.virtualFile && seen.add(child.path)) { - val psiD = psiManager.findFile(child) ?: continue - result.add(psiD) + var currentDir = file.containingDirectory + while (currentDir != null) { + for (child in currentDir.files) { + if (child.name.endsWith(".lyng.d") && child != file && seen.add(child.virtualFile.path)) { + result.add(child) } } - current = current.parent + currentDir = currentDir.parentDirectory + } + + if (result.isNotEmpty()) return@runReadAction result + + // Fallback for virtual/light files without a stable parent chain (e.g., tests) + val basePath = file.virtualFile?.path ?: return@runReadAction result + val scope = GlobalSearchScope.projectScope(file.project) + val dFiles = FilenameIndex.getAllFilesByExt(file.project, "d", scope) + for (vFile in dFiles) { + if (!vFile.name.endsWith(".lyng.d")) continue + if (vFile.path == basePath) continue + val parentPath = vFile.parent?.path ?: continue + if (basePath == parentPath || basePath.startsWith(parentPath.trimEnd('/') + "/")) { + if (seen.add(vFile.path)) { + psiManager.findFile(vFile)?.let { result.add(it) } + } + } + } + + if (result.isNotEmpty()) return@runReadAction result + + // Fallback: scan all Lyng files in project index and filter by .lyng.d + val lyngFiles = FileTypeIndex.getFiles(LyngFileType, scope) + for (vFile in lyngFiles) { + if (!vFile.name.endsWith(".lyng.d")) continue + if (vFile.path == basePath) continue + if (seen.add(vFile.path)) { + psiManager.findFile(vFile)?.let { result.add(it) } + } + } + + if (result.isNotEmpty()) return@runReadAction result + + // Final fallback: include all .lyng.d files in project scope + for (vFile in dFiles) { + if (!vFile.name.endsWith(".lyng.d")) continue + if (vFile.path == basePath) continue + if (seen.add(vFile.path)) { + psiManager.findFile(vFile)?.let { result.add(it) } + } } result } + fun getDeclarationFiles(file: PsiFile): List = runReadAction { + collectDeclarationFiles(file) + } + fun getBinding(file: PsiFile): BindingSnapshot? = runReadAction { getAnalysis(file)?.binding } @@ -92,20 +143,38 @@ object LyngAstManager { } if (built != null) { - val merged = built.mini - if (merged != null && !file.name.endsWith(".lyng.d")) { + val isDecl = file.name.endsWith(".lyng.d") + val merged = if (!isDecl && built.mini == null) { + MiniScript(MiniRange(built.source.startPos, built.source.startPos)) + } else { + built.mini + } + if (merged != null && !isDecl) { val dFiles = collectDeclarationFiles(file) for (df in dFiles) { - val dAnalysis = getAnalysis(df) - val dMini = dAnalysis?.mini ?: continue + val dMini = getAnalysis(df)?.mini ?: run { + val dText = df.viewProvider.contents.toString() + try { + val provider = IdeLenientImportProvider.create() + runBlocking { + LyngLanguageTools.analyze( + LyngAnalysisRequest(text = dText, fileName = df.name, importProvider = provider) + ) + }.mini + } catch (_: Throwable) { + null + } + } ?: continue merged.declarations.addAll(dMini.declarations) merged.imports.addAll(dMini.imports) } } val finalAnalysis = if (merged != null) { + val mergedImports = DocLookupUtils.canonicalImportedModules(merged, text) built.copy( mini = merged, - importedModules = DocLookupUtils.canonicalImportedModules(merged, text) + importedModules = mergedImports, + diagnostics = filterDiagnostics(built.diagnostics, merged, text, mergedImports) ) } else { built @@ -118,4 +187,45 @@ object LyngAstManager { } null } + + private fun filterDiagnostics( + diagnostics: List, + merged: MiniScript, + text: String, + importedModules: List + ): List { + if (diagnostics.isEmpty()) return diagnostics + val declaredTopLevel = merged.declarations.map { it.name }.toSet() + + val declaredMembers = linkedSetOf() + val aggregatedClasses = DocLookupUtils.aggregateClasses(importedModules, merged) + for (cls in aggregatedClasses.values) { + cls.members.forEach { declaredMembers.add(it.name) } + cls.ctorFields.forEach { declaredMembers.add(it.name) } + cls.classFields.forEach { declaredMembers.add(it.name) } + } + merged.declarations.filterIsInstance().forEach { en -> + DocLookupUtils.enumToSyntheticClass(en).members.forEach { declaredMembers.add(it.name) } + } + + val builtinTopLevel = linkedSetOf() + for (mod in importedModules) { + BuiltinDocRegistry.docsForModule(mod).forEach { builtinTopLevel.add(it.name) } + } + + return diagnostics.filterNot { diag -> + val msg = diag.message + if (msg.startsWith("unresolved name: ")) { + val name = msg.removePrefix("unresolved name: ").trim() + name in declaredTopLevel || name in builtinTopLevel + } else if (msg.startsWith("unresolved member: ")) { + val name = msg.removePrefix("unresolved member: ").trim() + val range = diag.range + val dotLeft = if (range != null) DocLookupUtils.findDotLeft(text, range.start) else null + dotLeft != null && name in declaredMembers + } else { + false + } + } + } } diff --git a/lyng-idea/src/test/kotlin/net/sergeych/lyng/idea/definitions/LyngDefinitionFilesTest.kt b/lyng-idea/src/test/kotlin/net/sergeych/lyng/idea/definitions/LyngDefinitionFilesTest.kt new file mode 100644 index 0000000..168c0cf --- /dev/null +++ b/lyng-idea/src/test/kotlin/net/sergeych/lyng/idea/definitions/LyngDefinitionFilesTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.idea.definitions + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.runBlocking +import net.sergeych.lyng.idea.docs.LyngDocumentationProvider +import net.sergeych.lyng.idea.navigation.LyngPsiReference +import net.sergeych.lyng.idea.settings.LyngFormatterSettings +import net.sergeych.lyng.idea.util.LyngAstManager +import net.sergeych.lyng.miniast.CompletionEngineLight + +class LyngDefinitionFilesTest : BasePlatformTestCase() { + + override fun getTestDataPath(): String = "" + + private fun enableCompletion() { + LyngFormatterSettings.getInstance(project).enableLyngCompletionExperimental = true + } + + private fun addDefinitionsFile() { + val defs = """ + /** Utilities exposed via .lyng.d */ + class Declared(val name: String) { + /** Size property */ + val size: Int = 0 + + /** Returns greeting. */ + fun greet(who: String): String = "hi " + who + } + + /** Top-level function. */ + fun topFun(x: Int): Int = x + 1 + """.trimIndent() + myFixture.addFileToProject("api.lyng.d", defs) + } + + fun test_CompletionsIncludeDefinitions() { + addDefinitionsFile() + enableCompletion() + run { + val code = """ + val v = top + """.trimIndent() + myFixture.configureByText("main.lyng", code) + val text = myFixture.editor.document.text + val caret = myFixture.caretOffset + val analysis = LyngAstManager.getAnalysis(myFixture.file) + val engine = runBlocking { CompletionEngineLight.completeSuspend(text, caret, analysis?.mini, analysis?.binding).map { it.name } } + assertTrue("Expected topFun from .lyng.d; got=$engine", engine.contains("topFun")) + } + run { + val code = """ + + """.trimIndent() + myFixture.configureByText("other.lyng", code) + val text = myFixture.editor.document.text + val caret = myFixture.caretOffset + val analysis = LyngAstManager.getAnalysis(myFixture.file) + val engine = runBlocking { CompletionEngineLight.completeSuspend(text, caret, analysis?.mini, analysis?.binding).map { it.name } } + assertTrue("Expected Declared from .lyng.d; got=$engine", engine.contains("Declared")) + } + } + + fun test_GotoDefinitionResolvesToDefinitionFile() { + addDefinitionsFile() + val code = """ + val x = topFun(1) + val y = Declared("x") + y.greet("me") + """.trimIndent() + myFixture.configureByText("main.lyng", code) + val offset = myFixture.caretOffset + val element = myFixture.file.findElementAt(offset) ?: myFixture.file.findElementAt((offset - 1).coerceAtLeast(0)) + assertNotNull("Expected element at caret for resolve", element) + val ref = LyngPsiReference(element!!) + val resolved = ref.resolve() + assertNotNull("Expected reference to resolve", resolved) + assertTrue("Expected .lyng.d target; got=${resolved!!.containingFile.name}", resolved.containingFile.name.endsWith(".lyng.d")) + } + + fun test_QuickDocUsesDefinitionDocs() { + addDefinitionsFile() + val code = """ + val y = Declared("x") + y.greet("me") + """.trimIndent() + myFixture.configureByText("main.lyng", code) + val provider = LyngDocumentationProvider() + val offset = myFixture.caretOffset + val element = myFixture.file.findElementAt(offset) ?: myFixture.file.findElementAt((offset - 1).coerceAtLeast(0)) + assertNotNull("Expected element at caret for doc", element) + val doc = provider.generateDoc(element, element) + assertNotNull("Expected Quick Doc", doc) + assertTrue("Doc should include summary; got=$doc", doc!!.contains("Returns greeting")) + } + + fun test_DiagnosticsIgnoreDefinitionSymbols() { + addDefinitionsFile() + val code = """ + val x = topFun(1) + val y = Declared("x") + y.greet("me") + """.trimIndent() + myFixture.configureByText("main.lyng", code) + val analysis = LyngAstManager.getAnalysis(myFixture.file) + val messages = analysis?.diagnostics?.map { it.message } ?: emptyList() + assertTrue("Should not report unresolved name for topFun", messages.none { it.contains("unresolved name: topFun") }) + assertTrue("Should not report unresolved name for Declared", messages.none { it.contains("unresolved name: Declared") }) + assertTrue("Should not report unresolved member for greet", messages.none { it.contains("unresolved member: greet") }) + } +} 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 668fb10..19807b1 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt @@ -178,6 +178,7 @@ object CompletionEngineLight { is MiniMemberDecl -> node.range else -> return } + if (range.start.source != src || range.end.source != src) return val start = src.offsetOf(range.start) val end = src.offsetOf(range.end).coerceAtMost(text.length) @@ -372,9 +373,12 @@ object CompletionEngineLight { val src = Source("", text) val provider = LenientImportProvider.create() Compiler.compileWithMini(src, provider, sink) - sink.build() + sink.build() ?: MiniScript(MiniRange(src.startPos, src.startPos)) } catch (_: Throwable) { - sink.build() + sink.build() ?: run { + val src = Source("", text) + MiniScript(MiniRange(src.startPos, src.startPos)) + } } } @@ -387,6 +391,7 @@ object CompletionEngineLight { // Text helpers private fun prefixAt(text: String, offset: Int): String { + if (text.isEmpty()) return "" val off = offset.coerceIn(0, text.length) var i = (off - 1).coerceAtLeast(0) while (i >= 0 && DocLookupUtils.isIdentChar(text[i])) i-- 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 3efe640..cddaf87 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt @@ -1163,6 +1163,7 @@ object DocLookupUtils { } fun findDotLeft(text: String, offset: Int): Int? { + if (text.isEmpty()) return null var i = (offset - 1).coerceAtLeast(0) while (i >= 0 && text[i].isWhitespace()) i-- return if (i >= 0 && text[i] == '.') i else null