better processing of lyng.d. files
This commit is contained in:
parent
9f10786a94
commit
8e10540257
@ -35,3 +35,27 @@ tasks.register<Exec>("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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
116
docs/lyng_d_files.md
Normal file
116
docs/lyng_d_files.md
Normal 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.
|
||||
65
docs/samples/definitions.lyng.d
Normal file
65
docs/samples/definitions.lyng.d
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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 {
|
||||
val title = "parameter ${p.name}${typeOf(p.type)} in ${fn.name}${signatureOf(fn)}"
|
||||
val sb = StringBuilder()
|
||||
|
||||
@ -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<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
|
||||
val targetFile = findFileForClass(file.project, owner) ?: file
|
||||
val targetMini = LyngAstManager.getMiniAst(targetFile)
|
||||
val targetMini = loadMini(targetFile)
|
||||
if (targetMini != null) {
|
||||
val targetSrc = targetMini.range.start.source
|
||||
val off = targetSrc.offsetOf(member.nameStart)
|
||||
@ -123,24 +129,37 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase<PsiEle
|
||||
}
|
||||
|
||||
private fun findFileForClass(project: Project, className: String): PsiFile? {
|
||||
val psiManager = PsiManager.getInstance(project)
|
||||
|
||||
// 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) {
|
||||
val mini = LyngAstManager.getMiniAst(file) ?: continue
|
||||
if (mini.declarations.any { (it is MiniClassDecl && it.name == className) || (it is MiniEnumDecl && it.name == className) }) {
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to full project scan
|
||||
val allFiles = FilenameIndex.getAllFilesByExt(project, "lyng", GlobalSearchScope.projectScope(project))
|
||||
for (vFile in allFiles) {
|
||||
val file = psiManager.findFile(vFile) ?: continue
|
||||
if (matchingFiles.contains(file)) continue // already checked
|
||||
val mini = LyngAstManager.getMiniAst(file) ?: continue
|
||||
if (mini.declarations.any { (it is MiniClassDecl && it.name == className) || (it is MiniEnumDecl && it.name == className) }) {
|
||||
for (file in collectLyngFiles(project)) {
|
||||
if (matchingFiles.contains(file) || matchingDeclFiles.contains(file)) continue // already checked
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -148,7 +167,7 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase<PsiEle
|
||||
}
|
||||
|
||||
private fun getPackageName(file: PsiFile): String? {
|
||||
val mini = LyngAstManager.getMiniAst(file) ?: return null
|
||||
val mini = loadMini(file) ?: return null
|
||||
return try {
|
||||
val pkg = mini.range.start.source.extractPackageName()
|
||||
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> {
|
||||
val results = mutableListOf<ResolveResult>()
|
||||
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<PsiEle
|
||||
}
|
||||
|
||||
for (d in mini.declarations) {
|
||||
if (!isLocalDecl(mini, d)) continue
|
||||
if (!membersOnly) {
|
||||
val dKind = when(d) {
|
||||
is net.sergeych.lyng.miniast.MiniFunDecl -> "Function"
|
||||
@ -216,6 +236,7 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase<PsiEle
|
||||
}
|
||||
|
||||
for (m in members) {
|
||||
if (m.range.start.source != src) continue
|
||||
val mKind = when(m) {
|
||||
is net.sergeych.lyng.miniast.MiniMemberFunDecl -> "Function"
|
||||
is net.sergeych.lyng.miniast.MiniMemberValDecl -> if (m.mutable) "Variable" else "Value"
|
||||
@ -229,5 +250,42 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase<PsiEle
|
||||
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()
|
||||
}
|
||||
|
||||
@ -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<MiniScript>("lyng.mini.cache")
|
||||
@ -52,22 +60,65 @@ object LyngAstManager {
|
||||
|
||||
private fun collectDeclarationFiles(file: PsiFile): List<PsiFile> = runReadAction {
|
||||
val psiManager = PsiManager.getInstance(file.project)
|
||||
var current = file.virtualFile?.parent
|
||||
val seen = mutableSetOf<String>()
|
||||
val result = mutableListOf<PsiFile>()
|
||||
|
||||
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<PsiFile> = 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<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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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") })
|
||||
}
|
||||
}
|
||||
@ -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("<engine>", 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("<engine>", 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--
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user