better processing of lyng.d. files

This commit is contained in:
Sergey Chernov 2026-02-23 15:04:25 +03:00
parent 9f10786a94
commit 8e10540257
11 changed files with 643 additions and 76 deletions

View File

@ -35,3 +35,27 @@ tasks.register<Exec>("generateDocs") {
description = "Generates a single-file documentation HTML using bin/generate_docs.sh" description = "Generates a single-file documentation HTML using bin/generate_docs.sh"
commandLine("./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()
)
}
}

View File

@ -9,9 +9,12 @@ should be compatible with other IDEA flavors, notably [OpenIDE](https://openide.
- reformat code (indents, spaces) - reformat code (indents, spaces)
- reformat on paste - reformat on paste
- smart enter key - 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. 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 > Recommended for IntelliJ-based IDEs: While IntelliJ can import TextMate bundles
> (Settings/Preferences → Editor → TextMate Bundles), the native Lyng plugin provides > (Settings/Preferences → Editor → TextMate Bundles), the native Lyng plugin provides
> better support (formatting, smart enter, background analysis, etc.). Prefer installing > better support (formatting, smart enter, background analysis, etc.). Prefer installing

116
docs/lyng_d_files.md Normal file
View File

@ -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<String, String>
/** 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.

View File

@ -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<String, String>
/** 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
}

View File

@ -35,13 +35,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch 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.idea.LyngIcons
import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyng.obj.getLyngExceptionMessageWithStackTrace
class RunLyngScriptAction : AnAction(LyngIcons.FILE) { class RunLyngScriptAction : AnAction(LyngIcons.FILE) {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@ -59,7 +53,9 @@ class RunLyngScriptAction : AnAction(LyngIcons.FILE) {
val isLyng = psiFile?.name?.endsWith(".lyng") == true val isLyng = psiFile?.name?.endsWith(".lyng") == true
e.presentation.isEnabledAndVisible = isLyng e.presentation.isEnabledAndVisible = isLyng
if (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 { } else {
e.presentation.text = "Run Lyng Script" e.presentation.text = "Run Lyng Script"
} }
@ -68,7 +64,6 @@ class RunLyngScriptAction : AnAction(LyngIcons.FILE) {
override fun actionPerformed(e: AnActionEvent) { override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return val project = e.project ?: return
val psiFile = getPsiFile(e) ?: return val psiFile = getPsiFile(e) ?: return
val text = psiFile.text
val fileName = psiFile.name val fileName = psiFile.name
val (console, toolWindow) = getConsoleAndToolWindow(project) val (console, toolWindow) = getConsoleAndToolWindow(project)
@ -76,40 +71,9 @@ class RunLyngScriptAction : AnAction(LyngIcons.FILE) {
toolWindow.show { toolWindow.show {
scope.launch { scope.launch {
try { console.print("--- Run is disabled ---\n", ConsoleViewContentType.SYSTEM_OUTPUT)
val lyngScope = Script.newScope() console.print("Lyng now runs in bytecode-only mode; the IDE no longer evaluates scripts.\n", ConsoleViewContentType.NORMAL_OUTPUT)
lyngScope.addFn("print") { console.print("Use the CLI to run scripts, e.g. `lyng run $fileName`.\n", ConsoleViewContentType.NORMAL_OUTPUT)
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)
}
} }
} }
} }

View File

@ -24,6 +24,7 @@ import com.intellij.openapi.editor.Editor
import com.intellij.openapi.util.TextRange import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiElement import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile import com.intellij.psi.PsiFile
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.highlight.offsetOf import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.idea.LyngLanguage import net.sergeych.lyng.idea.LyngLanguage
import net.sergeych.lyng.idea.util.LyngAstManager import net.sergeych.lyng.idea.util.LyngAstManager
@ -75,7 +76,51 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
// Single-source quick doc lookup // Single-source quick doc lookup
LyngLanguageTools.docAt(analysis, offset)?.let { info -> 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 // Try resolve to: function param at position, function/class/val declaration at position
@ -570,6 +615,55 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
return sb.toString() 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<MiniClassDecl>().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("<doc>", raw)
return MiniDoc.parse(MiniRange(src.startPos, src.startPos), raw.lines())
}
private fun renderParamDoc(fn: MiniFunDecl, p: MiniParam): String { private fun renderParamDoc(fn: MiniFunDecl, p: MiniParam): String {
val title = "parameter ${p.name}${typeOf(p.type)} in ${fn.name}${signatureOf(fn)}" val title = "parameter ${p.name}${typeOf(p.type)} in ${fn.name}${signatureOf(fn)}"
val sb = StringBuilder() val sb = StringBuilder()

View File

@ -20,12 +20,18 @@ package net.sergeych.lyng.idea.navigation
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange import com.intellij.openapi.util.TextRange
import com.intellij.psi.* import com.intellij.psi.*
import com.intellij.psi.search.FileTypeIndex
import com.intellij.psi.search.FilenameIndex import com.intellij.psi.search.FilenameIndex
import com.intellij.psi.search.GlobalSearchScope import com.intellij.psi.search.GlobalSearchScope
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.highlight.offsetOf 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.LyngAstManager
import net.sergeych.lyng.idea.util.TextCtx import net.sergeych.lyng.idea.util.TextCtx
import net.sergeych.lyng.miniast.* 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<PsiElement>(element, TextRange(0, element.textLength)) { class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase<PsiElement>(element, TextRange(0, element.textLength)) {
@ -58,7 +64,7 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase<PsiEle
// We need to find the actual PSI element for this member // We need to find the actual PSI element for this member
val targetFile = findFileForClass(file.project, owner) ?: file val targetFile = findFileForClass(file.project, owner) ?: file
val targetMini = LyngAstManager.getMiniAst(targetFile) val targetMini = loadMini(targetFile)
if (targetMini != null) { if (targetMini != null) {
val targetSrc = targetMini.range.start.source val targetSrc = targetMini.range.start.source
val off = targetSrc.offsetOf(member.nameStart) val off = targetSrc.offsetOf(member.nameStart)
@ -123,24 +129,37 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase<PsiEle
} }
private fun findFileForClass(project: Project, className: String): PsiFile? { private fun findFileForClass(project: Project, className: String): PsiFile? {
val psiManager = PsiManager.getInstance(project)
// 1. Try file with matching name first (optimization) // 1. Try file with matching name first (optimization)
val matchingFiles = FilenameIndex.getFilesByName(project, "$className.lyng", GlobalSearchScope.projectScope(project)) val scope = GlobalSearchScope.projectScope(project)
val psiManager = PsiManager.getInstance(project)
val matchingFiles = FileTypeIndex.getFiles(LyngFileType, scope)
.asSequence()
.filter { it.name == "$className.lyng" }
.mapNotNull { psiManager.findFile(it) }
.toList()
val matchingDeclFiles = FileTypeIndex.getFiles(LyngFileType, scope)
.asSequence()
.filter { it.name == "$className.lyng.d" }
.mapNotNull { psiManager.findFile(it) }
.toList()
for (file in matchingFiles) { for (file in matchingFiles) {
val mini = LyngAstManager.getMiniAst(file) ?: continue val mini = loadMini(file) ?: continue
if (mini.declarations.any { (it is MiniClassDecl && it.name == className) || (it is MiniEnumDecl && it.name == className) }) { if (mini.declarations.any { isLocalDecl(mini, it) && ((it is MiniClassDecl && it.name == className) || (it is MiniEnumDecl && it.name == className)) }) {
return file
}
}
for (file in matchingDeclFiles) {
val mini = loadMini(file) ?: continue
if (mini.declarations.any { isLocalDecl(mini, it) && ((it is MiniClassDecl && it.name == className) || (it is MiniEnumDecl && it.name == className)) }) {
return file return file
} }
} }
// 2. Fallback to full project scan // 2. Fallback to full project scan
val allFiles = FilenameIndex.getAllFilesByExt(project, "lyng", GlobalSearchScope.projectScope(project)) for (file in collectLyngFiles(project)) {
for (vFile in allFiles) { if (matchingFiles.contains(file) || matchingDeclFiles.contains(file)) continue // already checked
val file = psiManager.findFile(vFile) ?: continue val mini = loadMini(file) ?: continue
if (matchingFiles.contains(file)) continue // already checked if (mini.declarations.any { isLocalDecl(mini, it) && ((it is MiniClassDecl && it.name == className) || (it is MiniEnumDecl && it.name == className)) }) {
val mini = LyngAstManager.getMiniAst(file) ?: continue
if (mini.declarations.any { (it is MiniClassDecl && it.name == className) || (it is MiniEnumDecl && it.name == className) }) {
return file return file
} }
} }
@ -148,7 +167,7 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase<PsiEle
} }
private fun getPackageName(file: PsiFile): String? { private fun getPackageName(file: PsiFile): String? {
val mini = LyngAstManager.getMiniAst(file) ?: return null val mini = loadMini(file) ?: return null
return try { return try {
val pkg = mini.range.start.source.extractPackageName() val pkg = mini.range.start.source.extractPackageName()
if (pkg.startsWith("lyng.")) pkg else "lyng.$pkg" if (pkg.startsWith("lyng.")) pkg else "lyng.$pkg"
@ -172,19 +191,19 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase<PsiEle
private fun resolveGlobally(project: Project, name: String, membersOnly: Boolean = false, allowedPackages: Set<String>? = null): List<ResolveResult> { private fun resolveGlobally(project: Project, name: String, membersOnly: Boolean = false, allowedPackages: Set<String>? = null): List<ResolveResult> {
val results = mutableListOf<ResolveResult>() val results = mutableListOf<ResolveResult>()
val files = FilenameIndex.getAllFilesByExt(project, "lyng", GlobalSearchScope.projectScope(project))
val psiManager = PsiManager.getInstance(project) val psiManager = PsiManager.getInstance(project)
for (vFile in files) { for (file in collectLyngFiles(project)) {
val file = psiManager.findFile(vFile) ?: continue
// Filter by package if requested // Filter by package if requested
if (allowedPackages != null) { if (allowedPackages != null) {
val pkg = getPackageName(file) 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 val src = mini.range.start.source
fun addIfMatch(dName: String, nameStart: net.sergeych.lyng.Pos, dKind: String) { fun addIfMatch(dName: String, nameStart: net.sergeych.lyng.Pos, dKind: String) {
@ -197,6 +216,7 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase<PsiEle
} }
for (d in mini.declarations) { for (d in mini.declarations) {
if (!isLocalDecl(mini, d)) continue
if (!membersOnly) { if (!membersOnly) {
val dKind = when(d) { val dKind = when(d) {
is net.sergeych.lyng.miniast.MiniFunDecl -> "Function" is net.sergeych.lyng.miniast.MiniFunDecl -> "Function"
@ -216,6 +236,7 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase<PsiEle
} }
for (m in members) { for (m in members) {
if (m.range.start.source != src) continue
val mKind = when(m) { val mKind = when(m) {
is net.sergeych.lyng.miniast.MiniMemberFunDecl -> "Function" is net.sergeych.lyng.miniast.MiniMemberFunDecl -> "Function"
is net.sergeych.lyng.miniast.MiniMemberValDecl -> if (m.mutable) "Variable" else "Value" is net.sergeych.lyng.miniast.MiniMemberValDecl -> if (m.mutable) "Variable" else "Value"
@ -229,5 +250,42 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase<PsiEle
return results return results
} }
private fun collectLyngFiles(project: Project): List<PsiFile> {
val scope = GlobalSearchScope.projectScope(project)
val psiManager = PsiManager.getInstance(project)
val out = LinkedHashSet<PsiFile>()
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<Any> = emptyArray() override fun getVariants(): Array<Any> = emptyArray()
} }

View File

@ -21,14 +21,22 @@ import com.intellij.openapi.application.runReadAction
import com.intellij.openapi.util.Key import com.intellij.openapi.util.Key
import com.intellij.psi.PsiFile import com.intellij.psi.PsiFile
import com.intellij.psi.PsiManager 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 kotlinx.coroutines.runBlocking
import net.sergeych.lyng.binding.BindingSnapshot import net.sergeych.lyng.binding.BindingSnapshot
import net.sergeych.lyng.miniast.BuiltinDocRegistry
import net.sergeych.lyng.miniast.DocLookupUtils 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.miniast.MiniScript
import net.sergeych.lyng.tools.IdeLenientImportProvider import net.sergeych.lyng.tools.IdeLenientImportProvider
import net.sergeych.lyng.tools.LyngAnalysisRequest import net.sergeych.lyng.tools.LyngAnalysisRequest
import net.sergeych.lyng.tools.LyngAnalysisResult import net.sergeych.lyng.tools.LyngAnalysisResult
import net.sergeych.lyng.tools.LyngDiagnostic
import net.sergeych.lyng.tools.LyngLanguageTools import net.sergeych.lyng.tools.LyngLanguageTools
import net.sergeych.lyng.idea.LyngFileType
object LyngAstManager { object LyngAstManager {
private val MINI_KEY = Key.create<MiniScript>("lyng.mini.cache") private val MINI_KEY = Key.create<MiniScript>("lyng.mini.cache")
@ -52,22 +60,65 @@ object LyngAstManager {
private fun collectDeclarationFiles(file: PsiFile): List<PsiFile> = runReadAction { private fun collectDeclarationFiles(file: PsiFile): List<PsiFile> = runReadAction {
val psiManager = PsiManager.getInstance(file.project) val psiManager = PsiManager.getInstance(file.project)
var current = file.virtualFile?.parent
val seen = mutableSetOf<String>() val seen = mutableSetOf<String>()
val result = mutableListOf<PsiFile>() val result = mutableListOf<PsiFile>()
while (current != null) { var currentDir = file.containingDirectory
for (child in current.children) { while (currentDir != null) {
if (child.name.endsWith(".lyng.d") && child != file.virtualFile && seen.add(child.path)) { for (child in currentDir.files) {
val psiD = psiManager.findFile(child) ?: continue if (child.name.endsWith(".lyng.d") && child != file && seen.add(child.virtualFile.path)) {
result.add(psiD) 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 result
} }
fun getDeclarationFiles(file: PsiFile): List<PsiFile> = runReadAction {
collectDeclarationFiles(file)
}
fun getBinding(file: PsiFile): BindingSnapshot? = runReadAction { fun getBinding(file: PsiFile): BindingSnapshot? = runReadAction {
getAnalysis(file)?.binding getAnalysis(file)?.binding
} }
@ -92,20 +143,38 @@ object LyngAstManager {
} }
if (built != null) { if (built != null) {
val merged = built.mini val isDecl = file.name.endsWith(".lyng.d")
if (merged != null && !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) val dFiles = collectDeclarationFiles(file)
for (df in dFiles) { for (df in dFiles) {
val dAnalysis = getAnalysis(df) val dMini = getAnalysis(df)?.mini ?: run {
val dMini = dAnalysis?.mini ?: continue 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.declarations.addAll(dMini.declarations)
merged.imports.addAll(dMini.imports) merged.imports.addAll(dMini.imports)
} }
} }
val finalAnalysis = if (merged != null) { val finalAnalysis = if (merged != null) {
val mergedImports = DocLookupUtils.canonicalImportedModules(merged, text)
built.copy( built.copy(
mini = merged, mini = merged,
importedModules = DocLookupUtils.canonicalImportedModules(merged, text) importedModules = mergedImports,
diagnostics = filterDiagnostics(built.diagnostics, merged, text, mergedImports)
) )
} else { } else {
built built
@ -118,4 +187,45 @@ object LyngAstManager {
} }
null null
} }
private fun filterDiagnostics(
diagnostics: List<LyngDiagnostic>,
merged: MiniScript,
text: String,
importedModules: List<String>
): List<LyngDiagnostic> {
if (diagnostics.isEmpty()) return diagnostics
val declaredTopLevel = merged.declarations.map { it.name }.toSet()
val declaredMembers = linkedSetOf<String>()
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<MiniEnumDecl>().forEach { en ->
DocLookupUtils.enumToSyntheticClass(en).members.forEach { declaredMembers.add(it.name) }
}
val builtinTopLevel = linkedSetOf<String>()
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
}
}
}
} }

View File

@ -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<caret>
""".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 = """
<caret>
""".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.gre<caret>et("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.gre<caret>et("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") })
}
}

View File

@ -178,6 +178,7 @@ object CompletionEngineLight {
is MiniMemberDecl -> node.range is MiniMemberDecl -> node.range
else -> return else -> return
} }
if (range.start.source != src || range.end.source != src) return
val start = src.offsetOf(range.start) val start = src.offsetOf(range.start)
val end = src.offsetOf(range.end).coerceAtMost(text.length) val end = src.offsetOf(range.end).coerceAtMost(text.length)
@ -372,9 +373,12 @@ object CompletionEngineLight {
val src = Source("<engine>", text) val src = Source("<engine>", text)
val provider = LenientImportProvider.create() val provider = LenientImportProvider.create()
Compiler.compileWithMini(src, provider, sink) Compiler.compileWithMini(src, provider, sink)
sink.build() sink.build() ?: MiniScript(MiniRange(src.startPos, src.startPos))
} catch (_: Throwable) { } catch (_: Throwable) {
sink.build() sink.build() ?: run {
val src = Source("<engine>", text)
MiniScript(MiniRange(src.startPos, src.startPos))
}
} }
} }
@ -387,6 +391,7 @@ object CompletionEngineLight {
// Text helpers // Text helpers
private fun prefixAt(text: String, offset: Int): String { private fun prefixAt(text: String, offset: Int): String {
if (text.isEmpty()) return ""
val off = offset.coerceIn(0, text.length) val off = offset.coerceIn(0, text.length)
var i = (off - 1).coerceAtLeast(0) var i = (off - 1).coerceAtLeast(0)
while (i >= 0 && DocLookupUtils.isIdentChar(text[i])) i-- while (i >= 0 && DocLookupUtils.isIdentChar(text[i])) i--

View File

@ -1163,6 +1163,7 @@ object DocLookupUtils {
} }
fun findDotLeft(text: String, offset: Int): Int? { fun findDotLeft(text: String, offset: Int): Int? {
if (text.isEmpty()) return null
var i = (offset - 1).coerceAtLeast(0) var i = (offset - 1).coerceAtLeast(0)
while (i >= 0 && text[i].isWhitespace()) i-- while (i >= 0 && text[i].isWhitespace()) i--
return if (i >= 0 && text[i] == '.') i else null return if (i >= 0 && text[i] == '.') i else null