Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8b2b8d9
ADFA-4824: Document find-usages requirements and the COMMAND priority…
itsaky-adfa Aug 3, 2026
d9f15cf
ADFA-4824: Give user-invoked commands their own analysis priority
itsaky-adfa Aug 3, 2026
2f330f3
ADFA-4824: Map a caret to the declaration whose usages to search for
itsaky-adfa Aug 3, 2026
cd32bf0
ADFA-4824: Find usages of a Kotlin declaration across the workspace
itsaky-adfa Aug 3, 2026
42cb102
ADFA-4824: Add Find references to the Kotlin code actions menu
itsaky-adfa Aug 3, 2026
2f0193f
ADFA-4824: Stop the search panel reading each result file once per hit
itsaky-adfa Aug 3, 2026
5df6c84
ADFA-4824: Sync the find-usages doc with what was built
itsaky-adfa Aug 3, 2026
fdfd390
Merge branch 'stage' into worktree/ADFA-4824
itsaky-adfa Aug 4, 2026
d308b94
ADFA-4824: Fix the lossy module-dependents map
itsaky-adfa Aug 4, 2026
12e19c2
ADFA-4824: Stop the usage search losing or over-scanning results
itsaky-adfa Aug 4, 2026
9df2eec
ADFA-4824: Guard the search panel against a superseded publish
itsaky-adfa Aug 4, 2026
8ea969d
ADFA-4824: Sync the find-usages doc with the review fixes
itsaky-adfa Aug 4, 2026
6cf753c
ADFA-4824: Search a supertype's own module for dispatched usages
itsaky-adfa Aug 5, 2026
34ce596
ADFA-4824: Stop a twice-preempted plan reporting no references
itsaky-adfa Aug 5, 2026
a67280e
ADFA-4824: Stop the prefilter reading files it cannot use
itsaky-adfa Aug 5, 2026
7a40245
ADFA-4824: Fix the review nits in the find-usages docs and helpers
itsaky-adfa Aug 5, 2026
522fd47
ADFA-4824: Make two search tests able to fail
itsaky-adfa Aug 5, 2026
244730e
Merge branch 'stage' into worktree/ADFA-4824
itsaky-adfa Aug 5, 2026
c60833b
Merge branch 'stage' into worktree/ADFA-4824
itsaky-adfa Aug 6, 2026
3abd405
Merge branch 'stage' into worktree/ADFA-4824
itsaky-adfa Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 70 additions & 40 deletions app/src/main/java/com/itsaky/androidide/lsp/IDELanguageClientImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,22 @@
import com.itsaky.androidide.models.SearchResult;
import com.itsaky.androidide.tasks.TaskExecutor;
import com.itsaky.androidide.ui.CodeEditorView;
import com.itsaky.androidide.utils.FileIOUtils;
import com.itsaky.androidide.utils.FileUtils;
import com.itsaky.androidide.utils.FlashbarActivityUtilsKt;
import com.itsaky.androidide.utils.FlashbarUtilsKt;
import com.itsaky.androidide.utils.LSPUtils;
import io.github.rosemoe.sora.lang.diagnostic.DiagnosticsContainer;
import io.github.rosemoe.sora.text.Content;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import kotlin.Unit;
import org.slf4j.Logger;
Expand Down Expand Up @@ -107,6 +106,9 @@ public static void shutdown() {

private final Map<File, List<DiagnosticItem>> diagnostics = new HashMap<>();

/** Identifies the most recent {@link #showLocations(List)} request; older ones must not publish. */
private final AtomicInteger showLocationsRequest = new AtomicInteger();

protected EditorHandlerActivity activity;

private IDELanguageClientImpl(EditorHandlerActivity provider) {
Expand Down Expand Up @@ -271,56 +273,74 @@ public void showLocations(List<Location> locations) {
return;
}

boolean error = locations == null || locations.isEmpty();
activity.handleSearchResultVisibility(error);
// Claims the panel for this request. The publish below is asynchronous, so without this a slow
// request that started first would land last and overwrite the newer search the user is looking at.
final int request = showLocationsRequest.incrementAndGet();

boolean error = locations == null || locations.isEmpty();
if (error) {
activity.handleSearchResultVisibility(true);
activity
.setSearchResultAdapter(
new SearchListAdapter(Collections.emptyMap(), this::noOp, this::noOp));
return;
}

final Map<File, List<SearchResult>> results = new HashMap<>();
for (int i = 0; i < locations.size(); i++) {
try {
final Location loc = locations.get(i);
if (loc == null) {
continue;
}
// Group by file first. Reads then cost one pass per file instead of one full read per hit, which
// is what this used to do - and it did it on this thread. See SearchResultGrouping.
final Map<File, List<Location>> byFile = new LinkedHashMap<>();
for (final Location loc : locations) {
if (loc == null) {
continue;
}
byFile.computeIfAbsent(loc.getFile().toFile(), f -> new ArrayList<>()).add(loc);
}

final File file = loc.getFile().toFile();
if (!file.exists() || !file.isFile()) {
continue;
// A file with an open editor is resolved here, on the UI thread: its Content is live UI state
// that a background thread must not touch, and pulling a few lines out of it is substring work
// with no I/O. Everything else is read off this thread below.
final Map<File, List<SearchResult>> fromEditors = new HashMap<>();
final Map<File, List<Location>> onDisk = new LinkedHashMap<>();
for (final Map.Entry<File, List<Location>> entry : byFile.entrySet()) {
final var frag = findEditorByFile(entry.getKey());
if (frag != null && frag.getEditor() != null) {
final List<SearchResult> rows = SearchResultGrouping.INSTANCE.resultsFor(
entry.getKey(), entry.getValue(), frag.getEditor().getText());
if (!rows.isEmpty()) {
fromEditors.put(entry.getKey(), rows);
}
var frag = findEditorByFile(file);
Content content;
if (frag != null && frag.getEditor() != null) {
content = frag.getEditor().getText();
} else {
content = new Content(FileIOUtils.readFile2String(file));
}
final List<SearchResult> matches = results.containsKey(file) ? results.get(file) : new ArrayList<>();
Objects.requireNonNull(matches)
.add(
new SearchResult(
loc.getRange(),
file,
content.getLineString(loc.getRange().getStart().getLine()),
content
.subContent(
loc.getRange().getStart().getLine(),
loc.getRange().getStart().getColumn(),
loc.getRange().getEnd().getLine(),
loc.getRange().getEnd().getColumn())
.toString()));
results.put(file, matches);
} catch (Throwable th) {
LOG.error("Failed to show file location", th);
} else {
onDisk.put(entry.getKey(), entry.getValue());
}
}

activity.handleSearchResults(results);
if (onDisk.isEmpty()) {
publishLocations(fromEditors);
return;
}

// Some other search may publish (and bump the generation) while the read is in flight; capture it
// here so this request does not overwrite whatever replaced it.
final int generation = activity.getEditorViewModel().getCurrentSearchGeneration();

TaskExecutor.executeAsyncProvideError(
() -> SearchResultGrouping.INSTANCE.readFromDisk(onDisk),
(result, throwable) -> {
if (!canUseActivity()
|| request != showLocationsRequest.get()
|| generation != activity.getEditorViewModel().getCurrentSearchGeneration()) {
// Superseded, or the activity went away. Leave the panel to whoever owns it now: this
// request's results would be an answer to a question no longer on screen.
return;
}
final Map<File, List<SearchResult>> merged = new HashMap<>(fromEditors);
if (result != null) {
merged.putAll(result);
} else {
LOG.error("Failed to read search result files", throwable);
}
publishLocations(merged);
});
}

private Boolean applyActionEdits(@Nullable final IDEEditor editor, final CodeActionItem action) {
Expand Down Expand Up @@ -476,4 +496,14 @@ private List<DiagnosticGroup> mapAsGroup(Map<File, List<DiagnosticItem>> map) {
private Unit noOp(final Object obj) {
return Unit.INSTANCE;
}

/**
* Shows {@code results} in the search panel.
*
* Visibility and rows are committed together: a publish that never happens - superseded, or the activity recreated mid-read - must not leave the panel open with the "no results" placeholder hidden over the previous query's rows.
*/
private void publishLocations(final Map<File, List<SearchResult>> results) {
activity.handleSearchResultVisibility(results.isEmpty());
activity.handleSearchResults(results);
}
}
146 changes: 146 additions & 0 deletions app/src/main/java/com/itsaky/androidide/lsp/SearchResultGrouping.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package com.itsaky.androidide.lsp

import com.itsaky.androidide.models.Location
import com.itsaky.androidide.models.Range
import com.itsaky.androidide.models.SearchResult
import io.github.rosemoe.sora.text.Content
import org.slf4j.LoggerFactory
import java.io.BufferedReader
import java.io.File

/**
* Builds the search-results panel's rows for a set of [Location]s.
*
* Exists because the panel used to read every result file **in full, once per hit, on the main
* thread**: a file with twelve usages was read and materialised twelve times. Find usages made that a
* real cost rather than a latent one.
*
* A row needs only two short strings per hit - the hit's line, and the matched text - so nothing here
* retains a file's contents. Reads are one sequential pass per file, and peak memory is one line rather
* than one file. A per-file content cache would fix the repeated reads but hold every result file's text
* at once, which is the wrong trade on a phone.
*/
internal object SearchResultGrouping {
private val logger = LoggerFactory.getLogger(SearchResultGrouping::class.java)

/**
* Rows for [locations] in [file], built from already-available [lines] (0-based line number to text).
*
* A location whose lines are not all present is dropped: a stale location can point past the end of
* a file that has since been edited, and a row referring to a line that no longer exists is worse
* than no row.
*/
fun resultsFor(
file: File,
locations: List<Location>,
lines: Map<Int, String>,
): List<SearchResult> =
locations.mapNotNull { location ->
val range = location.range
val lineText = lines[range.start.line] ?: return@mapNotNull null
val match = matchedText(lineText, range, lines)
if (match == null) {
logger.debug("Dropping stale search result in {}", file.name)
return@mapNotNull null
}
SearchResult(range, file, lineText, match)
}

/** Rows for [locations] in [file], read from the live editor buffer [content]. */
fun resultsFor(
file: File,
locations: List<Location>,
content: Content,
): List<SearchResult> {
val lines =
linesNeededBy(locations)
.filter { it >= 0 && it < content.lineCount }
.associateWith { content.getLineString(it) }

return resultsFor(file, locations, lines)
}

/** Rows for every file in [byFile], reading each file exactly once. */
fun readFromDisk(byFile: Map<File, List<Location>>): Map<File, List<SearchResult>> =
byFile
.mapValues { (file, locations) -> resultsFor(file, locations, readLines(file, linesNeededBy(locations))) }
.filterValues { it.isNotEmpty() }

/** Every 0-based line number whose text [locations] need. */
fun linesNeededBy(locations: List<Location>): Set<Int> =
locations
.flatMapTo(mutableSetOf()) { location ->
location.range.start.line..location.range.end.line
}

/**
* The text of just the [wanted] lines of [file], in one sequential pass.
*
* Stops as soon as the last wanted line has been seen, and never holds more than the current line,
* so a hit near the top of a large file does not read the rest of it. Missing lines - a file shorter
* than the location claims, or an unreadable file - are simply absent from the result.
*/
fun readLines(
file: File,
wanted: Set<Int>,
): Map<Int, String> {
if (wanted.isEmpty()) {
return emptyMap()
}

val last = wanted.max()
val lines = HashMap<Int, String>(wanted.size)
return try {
file.bufferedReader().use { reader ->
reader.collectLines(wanted, last, lines)
}
lines
} catch (e: Exception) {
// A result file that has been deleted or is unreadable drops its rows, which is what the
// previous implementation did too by way of an exists() check per hit.
logger.debug("Could not read search result file {}", file, e)
lines
}
}

private fun BufferedReader.collectLines(
wanted: Set<Int>,
last: Int,
into: MutableMap<Int, String>,
) {
var number = 0
while (number <= last) {
val line = readLine() ?: return
if (number in wanted) {
into[number] = line
}
number++
}
}

/**
* The text [range] covers, given [firstLine] (the text of the line it starts on) and [lines] for the
* rest. Null when any line it spans is missing.
*/
private fun matchedText(
firstLine: String,
range: Range,
lines: Map<Int, String>,
): String? {
val start = range.start
val end = range.end
if (start.line == end.line) {
val from = start.column.coerceIn(0, firstLine.length)
return firstLine.substring(from, end.column.coerceIn(from, firstLine.length))
}

return buildString {
append(firstLine.substring(start.column.coerceIn(0, firstLine.length)))
for (line in (start.line + 1) until end.line) {
append('\n').append(lines[line] ?: return null)
}
val lastLine = lines[end.line] ?: return null
append('\n').append(lastLine.substring(0, end.column.coerceIn(0, lastLine.length)))
}
}
}
Loading
Loading