diff --git a/build.gradle.kts b/build.gradle.kts index 33c9efe..6b76062 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,7 +20,7 @@ dependencies { implementation(libs.jewel.standalone) implementation(libs.jewel.decorated.window) - + implementation(libs.fastutil) implementation(libs.jetbrains.compose.splitpane) implementation(compose.desktop.currentOs) { exclude(group = "org.jetbrains.compose.material") @@ -28,6 +28,7 @@ dependencies { implementation(libs.intellij.util.text.matching) implementation(libs.intellij.util.base) + implementation(libs.intellij.util.ui) implementation(libs.intellij.icons) implementation(fileTree(ghidraDistribution) { diff --git a/src/main/kotlin/framework/LookupElement.kt b/src/main/kotlin/framework/LookupElement.kt new file mode 100644 index 0000000..4cdba87 --- /dev/null +++ b/src/main/kotlin/framework/LookupElement.kt @@ -0,0 +1,20 @@ +package io.github.garyttierney.ghidralite.framework + +import androidx.compose.runtime.Composable + +interface LookupElement { + val key: Any + val label: String + val parent: LookupElement? + val icon: @Composable () -> Unit + + fun ancestors(): Sequence = sequence { + val p = parent + if (p != null) { + yield(p) + yieldAll(p.ancestors()) + } + } + + fun fullyQualified() = label +} diff --git a/src/main/kotlin/framework/db/SymbolDatabase.kt b/src/main/kotlin/framework/db/SymbolDatabase.kt index 7813f0c..4bf1b91 100644 --- a/src/main/kotlin/framework/db/SymbolDatabase.kt +++ b/src/main/kotlin/framework/db/SymbolDatabase.kt @@ -3,9 +3,44 @@ package io.github.garyttierney.ghidralite.framework.db import GhidraField import GhidraSchema import GhidraType +import androidx.compose.runtime.Composable +import com.intellij.icons.ExpUiIcons import db.DBRecord import db.Table import ghidra.program.model.symbol.SymbolType +import io.github.garyttierney.ghidralite.framework.LookupElement +import org.jetbrains.jewel.ui.component.* + +data class SymbolLookupDetails( + val id: Long, + val type: SymbolType, + override val label: String, + override val parent: SymbolLookupDetails? +) : LookupElement { + override val key: Any = id + override val icon = @Composable { + val iconPath = when (type) { + SymbolType.FUNCTION -> "/expui/nodes/function_dark.svg" + SymbolType.NAMESPACE -> "/expui/nodes/package_dark.svg" + SymbolType.GLOBAL_VAR -> "/expui/nodes/gvariable_dark.svg" + SymbolType.LIBRARY -> "/expui/nodes/library_dark.svg" + SymbolType.CLASS -> "/expui/nodes/class_dark.svg" + else -> "/expui/nodes/field_dark.svg" + } + + Icon( + resource = iconPath, + iconClass = ExpUiIcons::class.java, + contentDescription = type.toString() + ) + } + + override fun fullyQualified(): String { + return ancestors().fold("") { value, lookupElement -> + "${lookupElement.label}::$value" + } + } +} @GhidraSchema( @@ -24,7 +59,10 @@ interface SymbolRecord : GhidraRecord { var typeOrdinal: Byte var type: SymbolType get() = SymbolType.getSymbolType(typeOrdinal.toInt()) - set(value) { typeOrdinal = value.id } + set(value) { + typeOrdinal = value.id + } + } class SymbolDbTable(inner: Table) : GhidraTable(inner) { diff --git a/src/main/kotlin/framework/db/db.kt b/src/main/kotlin/framework/db/db.kt index ce4752a..016dfd2 100644 --- a/src/main/kotlin/framework/db/db.kt +++ b/src/main/kotlin/framework/db/db.kt @@ -66,6 +66,8 @@ class GhidraRecordSpliterator( abstract class GhidraTable(val inner: Table) { internal abstract fun from(record: DBRecord): T + fun get(id: Long) = from(inner.getRecord(id)) + fun all() = StreamSupport.stream( GhidraRecordSpliterator( this, diff --git a/src/main/kotlin/framework/search/Searcher.kt b/src/main/kotlin/framework/search/Searcher.kt index 503c098..3a09cd9 100644 --- a/src/main/kotlin/framework/search/Searcher.kt +++ b/src/main/kotlin/framework/search/Searcher.kt @@ -2,32 +2,41 @@ package io.github.garyttierney.ghidralite.framework.search import com.intellij.openapi.util.TextRange import com.intellij.psi.codeStyle.NameUtil -import io.github.garyttierney.ghidralite.framework.db.SymbolRecord +import io.github.garyttierney.ghidralite.framework.LookupElement +import io.github.garyttierney.ghidralite.framework.db.SymbolLookupDetails import io.github.garyttierney.ghidralite.framework.search.index.Indexes import java.util.concurrent.PriorityBlockingQueue import java.util.concurrent.atomic.AtomicInteger -public class Searcher(val indexes: Indexes) { - public suspend fun query(query: String, onDataAvailable: (List) -> Unit) { +class Searcher(private val indexes: Indexes) { + suspend fun query(query: String, onDataAvailable: (List) -> Unit) { val priorityQueue = PriorityBlockingQueue() val emissions = AtomicInteger(0) val queueCapacity = 50 - val matcher = NameUtil.buildMatcher(query).withCaseSensitivity(NameUtil.MatchingCaseSensitivity.NONE) - .withSeparators(".:_").typoTolerant().allOccurrences().build() - - indexes.query().collect { - val matchingDegree = matcher.matchingDegree(it.name, false) + val matcher = NameUtil.buildMatcher(query) + .withCaseSensitivity(NameUtil.MatchingCaseSensitivity.NONE) + .withSeparators(".:_") + .typoTolerant() + .allOccurrences() + .build() + + indexes.query().collect { + val matchingDegree = matcher.matchingDegree(it.label, false) val result = when { matchingDegree < 1 -> return@collect - else -> SearchResult(it.name, it.name, it.name, matchingDegree, it.key, mutableListOf()) + else -> SearchResult( + it, + matchingDegree, + mutableListOf() + ) } val beatsWorst = priorityQueue.size == queueCapacity && priorityQueue.peek().score < result.score val adding = beatsWorst || priorityQueue.size < queueCapacity if (adding) { - val fragments = matcher.matchingFragments(it.name) + val fragments = matcher.matchingFragments(it.label) result.fragments.addAll(fragments) priorityQueue.add(result) @@ -43,11 +52,8 @@ public class Searcher(val indexes: Indexes) { } data class SearchResult( - val name: String, - val subheading: String, - val type: String, + val element: LookupElement, val score: Int, - val uniqueKey: Long, val fragments: MutableList ) : Comparable { override fun compareTo(other: SearchResult): Int = score.compareTo(other.score) diff --git a/src/main/kotlin/framework/search/index/program/symbol/SymbolIndexLoader.kt b/src/main/kotlin/framework/search/index/program/symbol/SymbolIndexLoader.kt new file mode 100644 index 0000000..2f1fd5a --- /dev/null +++ b/src/main/kotlin/framework/search/index/program/symbol/SymbolIndexLoader.kt @@ -0,0 +1,34 @@ +package io.github.garyttierney.ghidralite.framework.search.index.program.symbol + +import ghidra.program.model.symbol.SymbolType +import io.github.garyttierney.ghidralite.framework.db.SymbolDbTable +import io.github.garyttierney.ghidralite.framework.db.SymbolLookupDetails +import io.github.garyttierney.ghidralite.framework.db.SymbolRecord +import io.github.garyttierney.ghidralite.framework.search.index.IndexBulkLoader +import it.unimi.dsi.fastutil.longs.Long2ObjectArrayMap +import it.unimi.dsi.fastutil.longs.Long2ObjectFunction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.stream.consumeAsFlow + +class SymbolIndexLoader(private val table: SymbolDbTable) : IndexBulkLoader { + override suspend fun load(): Flow { + val namespaceCache = Long2ObjectArrayMap() + + fun recordToLookup(record: SymbolRecord): SymbolLookupDetails { + val cacheLoader = Long2ObjectFunction { key -> recordToLookup(table.get(key)) } + + return SymbolLookupDetails( + record.key, + record.type, + record.name, + if (record.parentId == 0L) null else namespaceCache.computeIfAbsent(record.parentId, cacheLoader) + ) + } + + return table.all().consumeAsFlow() + .filterNot { it.type == SymbolType.LABEL || it.type == SymbolType.NAMESPACE } + .map(::recordToLookup) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/root/Workspace.kt b/src/main/kotlin/ui/root/Workspace.kt index 6423acc..a22f6e5 100644 --- a/src/main/kotlin/ui/root/Workspace.kt +++ b/src/main/kotlin/ui/root/Workspace.kt @@ -18,8 +18,8 @@ import io.github.garyttierney.ghidralite.framework.search.index.IndexWriter import io.github.garyttierney.ghidralite.framework.search.index.Indexes import io.github.garyttierney.ghidralite.framework.search.index.program.ProgramChangeSnapshotStrategy import io.github.garyttierney.ghidralite.framework.search.index.program.ProgramChangeWatcher -import io.github.garyttierney.ghidralite.framework.search.index.program.ProgramDbTableLoader import io.github.garyttierney.ghidralite.framework.search.index.program.programChangeInterest +import io.github.garyttierney.ghidralite.framework.search.index.program.symbol.SymbolIndexLoader import kotlinx.coroutines.launch object SymbolSnapshotStrategy : ProgramChangeSnapshotStrategy { @@ -56,13 +56,10 @@ class Workspace(val project: Project, val program: Program, val searcher: Search ) ) val symbolDbTable = SymbolDbTable(program.dbHandle.getTable("Symbols")) - val regex = Regex("^case|RTTI|Catch@|Unwind@|switch") - val symbolLoader = ProgramDbTableLoader(symbolDbTable) { - !it.name.matches(regex) - } + val symbolIndexLoader = SymbolIndexLoader(symbolDbTable) val symbolIndexWriter = IndexWriter(indexes, SymbolRecord::class) - indexes.load(symbolLoader) + indexes.load(symbolIndexLoader) GhidraWorkerScope.launch { symbolIndexWriter.run(symbolChanges) diff --git a/src/main/kotlin/ui/search/QuickSearchResultsView.kt b/src/main/kotlin/ui/search/QuickSearch.kt similarity index 81% rename from src/main/kotlin/ui/search/QuickSearchResultsView.kt rename to src/main/kotlin/ui/search/QuickSearch.kt index f8de173..e3d424e 100644 --- a/src/main/kotlin/ui/search/QuickSearchResultsView.kt +++ b/src/main/kotlin/ui/search/QuickSearch.kt @@ -1,6 +1,5 @@ package io.github.garyttierney.ghidralite.ui.search -import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable @@ -9,7 +8,6 @@ import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.* import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -17,11 +15,13 @@ import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.github.garyttierney.ghidralite.framework.search.SearchResult -import io.github.garyttierney.ghidralite.ui.internal.PreviewComponent +import kotlinx.coroutines.launch import org.jetbrains.jewel.foundation.lazy.* import org.jetbrains.jewel.foundation.lazy.tree.DefaultSelectableLazyColumnKeyActions import org.jetbrains.jewel.foundation.lazy.tree.KeyActions @@ -31,7 +31,6 @@ import org.jetbrains.jewel.ui.component.* import org.jetbrains.jewel.ui.theme.colorPalette import org.jetbrains.jewel.ui.theme.menuStyle -@OptIn(ExperimentalComposeUiApi::class) @Composable fun QuickSearch( items: List, @@ -42,7 +41,7 @@ fun QuickSearch( listActions: KeyActions = remember { DefaultSelectableLazyColumnKeyActions(DefaultSelectableColumnKeybindings) } ) { val itemListState = rememberSelectableLazyListState() - val itemKeys by derivedStateOf { items.map { SelectableLazyListKey.Selectable(it.uniqueKey) }.toList() } + val itemKeys by derivedStateOf { items.map { SelectableLazyListKey.Selectable(it.element.key) }.toList() } val handleListAction = { event: KeyEvent -> val itemListHandler = listActions.handleOnKeyEvent(event, itemKeys, itemListState, SelectionMode.Single) @@ -87,16 +86,25 @@ fun QuickSearchResultList( state: SelectableLazyListState = rememberSelectableLazyListState(), ) { val listTheme = Modifier.fillMaxSize().background(JewelTheme.menuStyle.colors.background) + val scope = rememberCoroutineScope() Box { SelectableLazyColumn( contentPadding = PaddingValues(2.dp), selectionMode = SelectionMode.Single, state = state, - modifier = listTheme.focusable(), + modifier = listTheme.then(Modifier.focusable()), + onSelectedIndexesChanged = { + val first = it.firstOrNull() + first?.let { + scope.launch { + state.scrollToItem(it) + } + } + } ) { items.forEach { - item(key = it.uniqueKey) { + item(key = it.element.key) { QuickSearchResult(item = it) } } @@ -130,12 +138,7 @@ fun SelectableLazyItemScope.QuickSearchResult(item: SearchResult) { verticalAlignment = Alignment.Bottom, modifier = rowTheme ) { - Icon( - "/nodes/method.svg", - contentDescription = "Test", - iconClass = SearchResult::class.java, - modifier = Modifier.size(16.dp) - ) + item.element.icon() val matchingRangeAnnotations = item.fragments.map { range -> AnnotatedString.Range( @@ -145,23 +148,22 @@ fun SelectableLazyItemScope.QuickSearchResult(item: SearchResult) { ) } + val annotatedLabel = buildAnnotatedString { + append(AnnotatedString(text = item.element.label, spanStyles = matchingRangeAnnotations)) + val parent = item.element.parent + + if (parent != null) { + append(" ") + pushStyle(SpanStyle(color = JewelTheme.globalColors.infoContent, fontWeight = FontWeight.Light)) + append(parent.fullyQualified()) + } + } + Text( - AnnotatedString(text = item.name, spanStyles = matchingRangeAnnotations), + annotatedLabel, maxLines = 1, modifier = Modifier.weight(1f, false), overflow = TextOverflow.Ellipsis ) } } - -@Preview -@Composable -internal fun QuickSearchPreview() = PreviewComponent { - val items = (0..10).map { - SearchResult( - "name", "subheading", "type", 1, it as Long, mutableListOf() - ) - } - - QuickSearch(items = items.toList(), query = "Search query", onQueryChanged = {}, onResultSelected = {}) -} \ No newline at end of file diff --git a/versions.toml b/versions.toml index f114757..ef1da8f 100644 --- a/versions.toml +++ b/versions.toml @@ -6,6 +6,7 @@ detekt = "1.23.4" dokka = "1.8.20" ideaGradlePlugin = "1.17.1" intellij = "241.14494.241" +fastutil = "8.5.13" ghidra = "11.1" jewel = "0.17.0" jna = "5.14.0" @@ -15,6 +16,7 @@ kotlinpoet = "1.15.2" kotlinterGradlePlugin = "3.16.0" kotlinxSerialization = "1.5.1" kotlinxBinaryCompat = "0.14.0" +lucene = "9.10.0" ksp = "1.9.20-1.0.14" poko = "0.13.1" @@ -25,12 +27,20 @@ commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", v decompose = { module = "com.arkivanov.decompose:decompose", version.ref = "decompose" } decompose-extensions-jetbrains = { module = "com.arkivanov.decompose:extensions-compose-jetbrains", version.ref = "decompose" } + +fastutil = { module = "it.unimi.dsi:fastutil", version.ref = "fastutil" } + filePicker = { module = "com.darkrockstudios:mpfilepicker", version = "3.1.0" } intellij-icons = { module = "com.jetbrains.intellij.platform:icons", version.ref = "intellij" } intellij-util-text-matching = { module = "com.jetbrains.intellij.platform:util-text-matching", version.ref = "intellij" } intellij-util-base = { module = "com.jetbrains.intellij.platform:util-base", version.ref = "intellij" } +intellij-util-ui = { module = "com.jetbrains.intellij.platform:util-ui", version.ref = "intellij" } + jetbrains-compose-splitpane = { module = "org.jetbrains.compose.components:components-splitpane-desktop", version.ref = "composeDesktop" } + +lucene-core = { module = "org.apache.lucene:lucene-core", version.ref = "lucene" } + kotlinSarif = { module = "io.github.detekt.sarif4k:sarif4k", version.ref = "kotlinSarif" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }