Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1c8cc2c
VFX System & UI Docking Improvements
HollowHorizon Sep 23, 2026
def24a9
Refine IDE docking and console panels
HollowHorizon Sep 24, 2026
b368840
Update ide.hss
HollowHorizon Sep 24, 2026
a9ec3bb
Add VFX system: runtime, renderers, editor UI
HollowHorizon Sep 25, 2026
df0cfb9
Refactor VFX editor, gizmo & particle look
HollowHorizon Sep 25, 2026
2619264
rewrite ui fonts & tabs
HollowHorizon Sep 25, 2026
31c5766
Add reusable expression editors and IDE icons
HollowHorizon Sep 25, 2026
dd6707d
Add HSS imports, theme vars, and UI refresh
HollowHorizon Sep 25, 2026
35528c3
Fix UI node identity on recomposition
HollowHorizon Sep 25, 2026
015b82e
Introduce console history abstraction
HollowHorizon Sep 25, 2026
3e2036d
Fix text field diagnostics and key handling
HollowHorizon Sep 25, 2026
afb7456
Add sky VFX node and renderer
HollowHorizon Sep 25, 2026
331f2a3
Document VFX and IDE updates
HollowHorizon Sep 26, 2026
1cd23f4
Add sub-emitters and glow/softness rendering
HollowHorizon Sep 26, 2026
08b1153
Clarify VFX sub-emitter and glow docs
HollowHorizon Sep 26, 2026
4e0fe0b
Add reusable graph canvas and animator scene UI
HollowHorizon Sep 26, 2026
8656e79
Add shader graph editor and material shaders
HollowHorizon Sep 26, 2026
d8cc593
Fix shader graph reload and UI transforms
HollowHorizon Sep 26, 2026
90655f4
Shader graph preview and normal nodes
HollowHorizon Sep 26, 2026
e0e0050
Add MCP addon, symbol search & snippet runner
HollowHorizon Sep 27, 2026
4cc9dee
Include MCP addon in build collection
HollowHorizon Sep 27, 2026
d9b7ec5
Refactor game-viewport window-pass handling
HollowHorizon Sep 27, 2026
70c45c2
Fix incompability with latest iris/sodium versions
HollowHorizon Sep 27, 2026
5d4ac47
Tune UI glyph edge smoothing and dropdown arrow
HollowHorizon Sep 27, 2026
1de8f19
Improve slot input and UI clipping
HollowHorizon Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ru.hollowhorizon.hollowengine.common.compiler
import ru.hollowhorizon.hollowengine.common.scripting.compiling.CompiledScript
import ru.hollowhorizon.hollowengine.common.scripting.compiling.HollowEngineScriptEvaluator
import ru.hollowhorizon.hollowengine.common.scripting.compiling.ScriptResult
import ru.hollowhorizon.hollowengine.common.scripting.compiling.convert
import ru.hollowhorizon.hollowengine.common.scripting.compiling.isClientSideScript
import ru.hollowhorizon.hollowengine.common.scripting.ide.ScriptEvaluationException
import kotlin.reflect.KClass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ru.hollowhorizon.hollowengine.common.scripting.cache.ScriptFingerprint
import ru.hollowhorizon.hollowengine.common.scripting.compiling.CompiledScript
import ru.hollowhorizon.hollowengine.common.scripting.compiling.ScriptCompilationContext
import ru.hollowhorizon.hollowengine.common.scripting.compiling.ScriptingCompiler
import ru.hollowhorizon.hollowengine.common.scripting.compiling.convert
import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.RemappingClasspath
import ru.hollowhorizon.hollowengine.common.scripting.ide.*
import ru.hollowhorizon.hollowengine.common.scripting.mixins.MixinSpecLayout
Expand Down Expand Up @@ -146,18 +147,3 @@ fun List<ScriptDefinition.FromConfigurations>.getDefinitionFor(name: String): Sc
)
}
}

fun ScriptDiagnostic.convert(): Diagnostic {
return Diagnostic(this.location?.let {
Range(
Position(it.start.line, it.start.col),
Position(it.end?.line ?: it.start.line, it.end?.col ?: it.start.col)
)
} ?: Range(Position(-1, -1), Position(-1, -1)), when (this.severity) {
ScriptDiagnostic.Severity.DEBUG -> Severity.DEBUG
ScriptDiagnostic.Severity.INFO -> Severity.INFO
ScriptDiagnostic.Severity.WARNING -> Severity.WARNING
ScriptDiagnostic.Severity.ERROR -> Severity.ERROR
ScriptDiagnostic.Severity.FATAL -> Severity.FATAL
}, this.message)
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import ru.hollowhorizon.hollowengine.common.ide.session.insight.findSignatureHel
import ru.hollowhorizon.hollowengine.common.ide.session.modules.KaRekotLibraryModule
import ru.hollowhorizon.hollowengine.common.ide.session.modules.KaScriptModule
import ru.hollowhorizon.hollowengine.common.scripting.ide.*
import ru.hollowhorizon.hollowengine.common.ide.session.symbols.searchSymbols
import ru.hollowhorizon.hollowengine.common.ide.session.symbols.symbolSource
import ru.hollowhorizon.hollowengine.common.scripting.source.SandboxScriptSource
import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptImports
import ru.hollowhorizon.hollowengine.common.scripting.source.ScriptRegistry
import java.io.File
Expand Down Expand Up @@ -303,6 +306,21 @@ class ScriptingAnalyzerImpl(
val file = getOrCreateFile(name, text)
return collectImportDiagnostics(name, file) + diagnosticCode(file)
}

@Synchronized
override fun searchSymbols(query: String, limit: Int): List<SymbolMatch> {
return searchSymbols(getOrCreateFile(SymbolLookupScript, ""), query, limit)
}

@Synchronized
override fun symbolSource(qualifiedName: String): DefinitionLocation? {
return symbolSource(getOrCreateFile(SymbolLookupScript, ""), qualifiedName)
}

private companion object {
/** An empty sandbox script, so name lookups see the classpath the project's own scripts compile against. */
val SymbolLookupScript = "${SandboxScriptSource.SCRIPTS_DIRECTORY}/.symbol-lookup.kts"
}
}

private fun removeFromPsiManager(file: KtFile) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ private fun CompletionItemsCollector.completeScopeContext(file: KtFile, element:
}

context(session: KaSession)
private fun getKotlinDeclarationsFromIndex(ktFile: KtFile, filter: (Name) -> Boolean): Sequence<KaDeclarationSymbol> {
internal fun getKotlinDeclarationsFromIndex(ktFile: KtFile, filter: (Name) -> Boolean): Sequence<KaDeclarationSymbol> {
with(session) {
val factory = KotlinDeclarationProviderFactory.getInstance(ktFile.project)
val declarationProvider = factory.createDeclarationProvider(analysisScope, useSiteModule)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ private fun List<KaSymbol>.pickDefinitionSymbol(): KaDeclarationSymbol? {
?: firstNotNullOfOrNull { it as? KaDeclarationSymbol }
}

private fun ScriptingAnalyzerImpl.definitionForSymbol(symbol: KaDeclarationSymbol): DefinitionLocation? {
internal fun ScriptingAnalyzerImpl.definitionForSymbol(symbol: KaDeclarationSymbol): DefinitionLocation? {
val targetPsi = symbol.psi
runCatching {
targetPsi?.toDefinitionLocation()?.let { return it }
Expand Down Expand Up @@ -174,7 +174,7 @@ private object CfrDefinitionDecompiler {
if (!classPath.endsWith(".class") || "!/" !in classPath) return null
val key = classPath
val text = cache.getOrPut(key) {
decompileClass(classPath) ?: return null
decompileClass(classFile) ?: return null
}
return DefinitionLocation(
path = "decompiled/${classPath.substringAfterLast("!/").removeSuffix(".class")}.java",
Expand All @@ -184,25 +184,21 @@ private object CfrDefinitionDecompiler {
)
}

private fun decompileClass(classPath: String): String? {
val jarPath = classPath.substringBefore("!/")
val entryName = classPath.substringAfter("!/")
/**
* Copies [classFile] and its nested classes out of whatever file system holds them, a jar or the
* JDK's module image, into a directory CFR can read, and decompiles the outer class there.
*/
private fun decompileClass(classFile: VirtualFile): String? {
val entryName = classFile.path.substringAfterLast("!/")
val outerClass = classFile.nameWithoutExtension.substringBefore('$')
val siblings = classFile.parent?.children.orEmpty()
.filter { it.extension == "class" && it.nameWithoutExtension.substringBefore('$') == outerClass }
val tempRoot = Files.createTempDirectory("hollowengine-cfr-")
return try {
JarFile(File(jarPath)).use { jar ->
val outerClassPrefix = entryName.removeSuffix(".class").substringBefore('$')
jar.entries().asSequence()
.filter { !it.isDirectory && it.name.endsWith(".class") && it.name.removeSuffix(".class").substringBefore('$') == outerClassPrefix }
.forEach { entry ->
val target = tempRoot.resolve(entry.name)
Files.createDirectories(target.parent)
jar.getInputStream(entry).use { input ->
Files.copy(input, target)
}
}
}
val targetClass = tempRoot.resolve(entryName)
CfrSourceDecompiler.decompile(targetClass.absolutePathString())
val packageDirectory = tempRoot.resolve(entryName).parent
Files.createDirectories(packageDirectory)
siblings.forEach { sibling -> Files.write(packageDirectory.resolve(sibling.name), sibling.contentsToByteArray()) }
CfrSourceDecompiler.decompile(tempRoot.resolve(entryName).absolutePathString())
} finally {
tempRoot.toFile().deleteRecursively()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package ru.hollowhorizon.hollowengine.common.ide.session.symbols

import org.jetbrains.kotlin.analysis.api.KaSession
import org.jetbrains.kotlin.analysis.api.analyze
import org.jetbrains.kotlin.analysis.api.renderer.types.impl.KaTypeRendererForSource
import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol
import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol
import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol
import org.jetbrains.kotlin.analysis.api.symbols.KaDeclarationSymbol
import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol
import org.jetbrains.kotlin.analysis.api.symbols.KaPropertySymbol
import org.jetbrains.kotlin.analysis.api.symbols.KaSymbolVisibility
import org.jetbrains.kotlin.analysis.api.symbols.markers.KaTypeParameterOwnerSymbol
import org.jetbrains.kotlin.analysis.api.types.KaType
import org.jetbrains.kotlin.name.ClassId
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.name.Name
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.types.Variance
import ru.hollowhorizon.hollowengine.common.ide.session.ScriptingAnalyzerImpl
import ru.hollowhorizon.hollowengine.common.ide.session.completion.getKotlinDeclarationsFromIndex
import ru.hollowhorizon.hollowengine.common.ide.session.definition.definitionForSymbol
import ru.hollowhorizon.hollowengine.common.ide.session.index.JavaClassNameIndex
import ru.hollowhorizon.hollowengine.common.scripting.ide.DefinitionLocation
import ru.hollowhorizon.hollowengine.common.scripting.ide.SymbolKind
import ru.hollowhorizon.hollowengine.common.scripting.ide.SymbolMatch

/**
* Classes from the class-file index and Kotlin top-level declarations from the declaration index, both
* seen from [file], ranked by how closely their simple name matches the query.
*/
internal fun ScriptingAnalyzerImpl.searchSymbols(file: KtFile, query: String, limit: Int): List<SymbolMatch> {
val pattern = SymbolQuery.parse(query) ?: return emptyList()
val found = LinkedHashMap<String, RankedMatch>()

fun offer(rank: Int, match: SymbolMatch) {
val key = match.qualifiedName + match.signature
val existing = found[key]
if (existing == null || rank < existing.rank) found[key] = RankedMatch(rank, match)
}

analyze(file) {
getKotlinDeclarationsFromIndex(file) { name -> pattern.rank(name) != null }.forEach { symbol ->
val match = symbolMatch(symbol) ?: return@forEach
if (!pattern.qualifies(match.qualifiedName)) return@forEach
val rank = pattern.rank(match.qualifiedName.substringAfterLast('.')) ?: return@forEach
offer(rank, match)
}
}

JavaClassNameIndex.getInstance(project).classes({ name -> pattern.rank(name) != null }).forEach { javaClass ->
if (javaClass.name.asString().first().isDigit() || !pattern.qualifies(javaClass.fqName)) return@forEach
val rank = pattern.rank(javaClass.name) ?: return@forEach
val kind = if (javaClass.isAnnotation) SymbolKind.ANNOTATION else SymbolKind.CLASS
offer(rank, SymbolMatch(kind, javaClass.fqName))
}

return found.values
.sortedWith(
compareBy<RankedMatch> { it.rank }
.thenBy { originOrder(it.match.qualifiedName) }
.thenBy { it.match.kind }
.thenBy { it.match.qualifiedName.length }
.thenBy { it.match.qualifiedName },
)
.take(limit)
.map(RankedMatch::match)
}

/**
* The declaration [qualifiedName] names: a class (nested ones spelled with dots), a member of one, or a
* top-level function or property. Overloads resolve to the first one declared.
*/
internal fun ScriptingAnalyzerImpl.symbolSource(file: KtFile, qualifiedName: String): DefinitionLocation? {
val fqName = runCatching { FqName(qualifiedName.trim()) }.getOrNull() ?: return null
if (fqName.isRoot) return null
val symbol = analyze(file) {
findClassNamed(fqName)
?: findMember(fqName)
?: findTopLevelCallables(fqName.parent(), fqName.shortName()).firstOrNull()
} ?: return null
return definitionForSymbol(symbol)
}

private class RankedMatch(val rank: Int, val match: SymbolMatch)

/** Among equally close names, the game's and the engine's come first and the JDK's internals last. */
private fun originOrder(qualifiedName: String): Int = when {
PREFERRED_PACKAGES.any(qualifiedName::startsWith) -> 0
INTERNAL_PACKAGES.any(qualifiedName::startsWith) -> 2
else -> 1
}

private val PREFERRED_PACKAGES = listOf("net.minecraft.", "com.mojang.", "ru.hollowhorizon.")
private val INTERNAL_PACKAGES = listOf("sun.", "com.sun.", "jdk.", "org.w3c.", "org.xml.", "javax.swing.", "java.awt.")

/** A query split into the simple name to match and the part of the qualifier it was written with. */
private class SymbolQuery(private val name: String, private val qualifier: String) {
/** Lower is closer; null when [simpleName] does not match at all. */
fun rank(simpleName: Name): Int? = rank(simpleName.asString())

fun rank(simpleName: String): Int? = when {
simpleName == name -> 0
simpleName.equals(name, ignoreCase = true) -> 1
simpleName.startsWith(name, ignoreCase = true) -> 2
simpleName.contains(name, ignoreCase = true) -> 3
else -> null
}

fun qualifies(qualifiedName: String): Boolean =
qualifier.isEmpty() || qualifiedName.substringBeforeLast('.', "").contains(qualifier, ignoreCase = true)

companion object {
fun parse(query: String): SymbolQuery? {
val trimmed = query.trim().trim('.')
val name = trimmed.substringAfterLast('.')
if (name.isEmpty()) return null
return SymbolQuery(name, trimmed.substringBeforeLast('.', ""))
}
}
}

/** Null for what a script cannot reach: anything not public, and local or anonymous declarations. */
context(session: KaSession)
private fun symbolMatch(symbol: KaDeclarationSymbol): SymbolMatch? = with(session) {
if (symbol.visibility != KaSymbolVisibility.PUBLIC) return null
when (symbol) {
is KaClassLikeSymbol -> SymbolMatch(SymbolKind.CLASS, symbol.classId?.asFqNameString() ?: return null)
is KaCallableSymbol -> SymbolMatch(
kind = if (symbol is KaFunctionSymbol) SymbolKind.FUNCTION else SymbolKind.PROPERTY,
qualifiedName = symbol.callableId?.asSingleFqName()?.asString() ?: return null,
signature = signature(symbol),
)
else -> null
}
}

/**
* A one-line declaration with its receiver and type parameters, and `= …` after every parameter that
* has a default, which is what tells a caller which arguments it can leave out.
*/
context(session: KaSession)
private fun signature(symbol: KaCallableSymbol): String = with(session) {
fun render(type: KaType) = type.render(KaTypeRendererForSource.WITH_SHORT_NAMES, Variance.INVARIANT)
buildString {
append(
when (symbol) {
is KaFunctionSymbol -> "fun "
is KaPropertySymbol -> if (symbol.isVal) "val " else "var "
else -> ""
}
)
val typeParameters = (symbol as? KaTypeParameterOwnerSymbol)?.typeParameters.orEmpty()
if (typeParameters.isNotEmpty()) {
append(typeParameters.joinToString(", ", "<", "> ") { it.name.asString() })
}
symbol.receiverParameter?.let { receiver -> append(render(receiver.returnType)).append('.') }
append(symbol.callableId?.callableName?.asString().orEmpty())
if (symbol is KaFunctionSymbol) {
append(symbol.valueParameters.joinToString(", ", "(", ")") { parameter ->
val vararg = if (parameter.isVararg) "vararg " else ""
val default = if (parameter.hasDefaultValue) " = …" else ""
"$vararg${parameter.name.asString()}: ${render(parameter.returnType)}$default"
})
}
append(": ").append(render(symbol.returnType))
}
}

/** Tries every split of [fqName] into package and class, the longest package first. */
context(session: KaSession)
private fun findClassNamed(fqName: FqName): KaClassSymbol? = with(session) {
val segments = fqName.pathSegments()
for (packageSize in segments.size - 1 downTo 0) {
val packageFqName = FqName.fromSegments(segments.take(packageSize).map(Name::asString))
val relativeName = FqName.fromSegments(segments.drop(packageSize).map(Name::asString))
findClass(ClassId(packageFqName, relativeName, isLocal = false))?.let { return it }
}
null
}

context(session: KaSession)
private fun findMember(fqName: FqName): KaDeclarationSymbol? = with(session) {
val owner = findClassNamed(fqName.parent()) ?: return null
val scope = owner.combinedDeclaredMemberScope
scope.callables(fqName.shortName()).firstOrNull() ?: scope.classifiers(fqName.shortName()).firstOrNull()
}
64 changes: 64 additions & 0 deletions addons/compiler/src/test/kotlin/SymbolLookupTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.TestInstance
import ru.hollowhorizon.hollowengine.common.ScriptingEnvironmentImpl
import ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider
import ru.hollowhorizon.hollowengine.common.scripting.deobf.mappings.Mappings
import ru.hollowhorizon.hollowengine.common.scripting.ide.SymbolKind
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SymbolLookupTest {
private val fixture = AnalysisEnvironmentTestFixture {
ScriptingEnvironmentImpl(
javaHome = File(System.getProperty("java.home")),
classpath = System.getProperty("java.class.path").split(File.pathSeparator)
.filter(String::isNotBlank).map(::File).filter(File::exists).distinctBy { it.absoluteFile.normalize() },
scriptTypes = listOf(ScriptClassProvider("kts", "kotlin.Any")),
mappings = Mappings.EMPTY,
)
}

private val analyzer get() = fixture.environment.analyzer

@BeforeAll
fun startEnvironment() = fixture.start()

@AfterAll
fun closeEnvironment() = fixture.close()

@Test
fun `an exact name ranks before names that only contain it`() {
val matches = analyzer.searchSymbols("ScriptClassProvider", 20)
assertEquals("ru.hollowhorizon.hollowengine.common.scripting.ScriptClassProvider", matches.first().qualifiedName)
assertEquals(SymbolKind.CLASS, matches.first().kind)
}

@Test
fun `extension functions show their receiver and which parameters have defaults`() {
val match = analyzer.searchSymbols("collections.joinToString", 20)
.first { it.kind == SymbolKind.FUNCTION && it.qualifiedName == "kotlin.collections.joinToString" }
assertTrue(".joinToString(separator: CharSequence = …" in match.signature, match.signature)
}

@Test
fun `a qualifier narrows the search to matching packages`() {
val matches = analyzer.searchSymbols("util.ArrayList", 50)
assertTrue(matches.isNotEmpty())
assertTrue(matches.all { "util" in it.qualifiedName.substringBeforeLast('.') }, matches.toString())
}

@Test
fun `sources resolve classes, nested classes, members and top-level functions`() {
val member = assertNotNull(analyzer.symbolSource("java.util.ArrayList.trimToSize"))
assertTrue(member.text!!.startsWith("trimToSize", member.offset), member.path)
assertNotNull(analyzer.symbolSource("java.util.Map.Entry"))
assertNotNull(analyzer.symbolSource("kotlin.collections.joinToString"))
assertNull(analyzer.symbolSource("no.such.Thing"))
}
}
Loading
Loading