fix(run): resolve stack frames from all exact source names (#2313)

Stack-trace navigation only searched the first 50 fuzzy file matches, so in a project with many files of the same name the project source could be skipped in favour of a library or decompiled class. Frames now look up every indexed file with the exact source basename (new find_files_named) and then apply the existing package-path and declaration checks. Covers Java, Kotlin (including sources outside their package directory), Scala and Groovy; older remote servers fall back to their full fuzzy list. Refs #1660.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-10 00:39:59 +08:00
1 джерело 658c38a193
коміт 226ac26954
9 files changed
+109 -7

No files matched your search

+6
Переглянути файл
@@ -41,6 +41,11 @@ pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeRe
Ok(host.project(window)?.index.find_files(&query, limit as usize))
}
pub async fn find_files_named(host: &Host, window: &str, name: String) -> IdeResult<Vec<IpcPath>> {
let project = host.project(window)?;
blocking(move || Ok(project.index.files_named(&name).into_iter().map(Into::into).collect())).await
}
pub fn find_dirs(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_dirs(&query, limit as usize))
}
@@ -167,6 +172,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_files_named", |c| async move { find_files_named(&c.host, &c.window, c.arg("name")?).await });
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_symbols", |c| async move {
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
+1
Переглянути файл
@@ -290,6 +290,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "deploy_coverage"
| "deploy_test_connection"
| "find_files"
| "find_files_named"
| "find_dirs"
| "find_symbols"
| "mark_recent"
+9
Переглянути файл
@@ -234,6 +234,15 @@ impl ProjectIndex {
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
}
/// All indexed files with this exact basename, for resolving printed source locations.
/// Unlike interactive fuzzy search, this must not truncate candidates before package matching.
pub fn files_named(&self, name: &str) -> Vec<PathBuf> {
let data = self.shared.data.read();
let mut files: Vec<_> = data.files.iter().flatten().filter(|file| file.name() == name).collect();
files.sort_by(|a, b| a.rel.len().cmp(&b.rel.len()).then_with(|| a.rel.cmp(&b.rel)));
files.into_iter().map(|file| file.path.clone()).collect()
}
/// Up to `limit` indexed files below `dir` (VCS-ignored ones left out) whose path relative to
/// `dir` satisfies `matches`: a file list without walking the disk. `None` while nothing is
/// indexed yet (first scan without a cache).
+21
Переглянути файл
@@ -57,6 +57,27 @@ fn files(idx: &ProjectIndex, q: &str) -> Vec<String> {
idx.find_files(q, 20).into_iter().map(|m| m.rel_path).collect()
}
#[test]
fn exact_file_names_keep_all_source_candidates() {
let f = Fixture::new(&[
("src/OtherOrders.kt", "package other"),
("src/Orders.kt.bak", "backup"),
("target/Orders.kt", "excluded"),
("src/orders.kt", "different case"),
]);
for i in 0..65 {
f.write(&format!("module{i}/Orders.kt"), &format!("package module{i}"));
}
let source = f.write("service/src/Orders.kt", "package com.example.orders");
let idx = f.open();
let candidates = idx.files_named("Orders.kt");
assert_eq!(candidates.len(), 66);
assert!(candidates.contains(&source));
assert!(candidates.iter().all(|path| path.file_name().unwrap() == "Orders.kt"));
assert_eq!(candidates, idx.files_named("Orders.kt"));
assert!(idx.files_named("Missing.kt").is_empty());
}
fn symbols(idx: &ProjectIndex, q: &str, filter: SymbolFilter) -> Vec<String> {
idx.find_symbols(q, filter, 20).into_iter().map(|m| m.name).collect()
}
+1
Переглянути файл
@@ -209,6 +209,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
nav::index_file_extensions,
nav::spring_route_sources,
nav::find_files,
nav::find_files_named,
nav::find_dirs,
nav::find_symbols,
nav::mark_recent,
+6
Переглянути файл
@@ -28,6 +28,12 @@ pub fn find_files(window: Window, state: State<'_, AppState>, query: String, lim
ide_host::ops::nav::find_files(&state.host, window.label(), query, limit)
}
#[tauri::command]
#[specta::specta]
pub async fn find_files_named(window: Window, state: State<'_, AppState>, name: String) -> IdeResult<Vec<IpcPath>> {
ide_host::ops::nav::find_files_named(&state.host, window.label(), name).await
}
/// Folders holding indexed files, for `@` references.
#[tauri::command]
#[specta::specta]
+1
Переглянути файл
@@ -368,6 +368,7 @@ export const commands = {
/** Spring Web mapping sources for the request-path index of Search Everywhere. */
springRouteSources: () => typedError<RouteSource[], IdeError>(__TAURI_INVOKE("spring_route_sources")),
findFiles: (query: string, limit: number) => typedError<FileMatch[], IdeError>(__TAURI_INVOKE("find_files", { query, limit })),
findFilesNamed: (name: string) => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("find_files_named", { name })),
/** Folders holding indexed files, for `@` references. */
findDirs: (query: string, limit: number) => typedError<FileMatch[], IdeError>(__TAURI_INVOKE("find_dirs", { query, limit })),
findSymbols: (query: string, filter: SymbolFilter, limit: number) => typedError<SymbolMatch[], IdeError>(__TAURI_INVOKE("find_symbols", { query, filter, limit })),
+51 -1
Переглянути файл
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { jvmFrameTarget } from "./links";
import { useRemote } from "@/platform/remote";
const files: Record<string, string> = {
"/p/src/main/java/com/acme/Util.java": "package com.acme;\nclass Util {}",
@@ -15,9 +16,12 @@ vi.mock("@/platform/ipc", () => ({
commands: {
fsReadDir: async () => [],
fsReadText: async (path: string) => files[path] ?? "",
findFiles: async (name: string) =>
findFilesNamed: async (name: string) => Object.keys(files).filter((p) => p.endsWith(`/${name}`)),
indexStatus: async () => ({ files: Object.keys(files).length }),
findFiles: async (name: string, limit: number) =>
Object.keys(files)
.filter((p) => p.endsWith(`/${name}`))
.slice(0, limit)
.map((path) => ({ path, relPath: path.slice(3) })),
lspWorkspaceSymbols: async () => symbols.list,
},
@@ -28,6 +32,7 @@ const { resolveTarget } = await import("./navigate");
describe("JVM stack frame resolution", () => {
afterEach(() => {
symbols.list = [];
useRemote.setState({ session: null });
});
it("picks the file of the frame's package, not any file of that name", async () => {
@@ -43,6 +48,51 @@ describe("JVM stack frame resolution", () => {
);
});
it.each(["java", "kt", "scala", "groovy"])("keeps the package in a crowded %s source lookup", async (ext) => {
const otherPaths = Array.from({ length: 60 }, (_, i) => `/p/module${i}/src/other${i}/AService.${ext}`);
const source = `/p/service/src/main/${ext}/com/example/AService.${ext}`;
for (const [i, path] of otherPaths.entries()) files[path] = `package other${i};`;
files[source] = "package com.example;";
symbols.list = [
{
name: "AService",
kind: "class",
container: "com.example",
location: { path: "jdt://contents/AService.class", uri: "jdt://contents/AService.class" },
},
];
try {
expect(await resolveTarget(jvmFrameTarget("com.example.AService", `AService.${ext}`, 52), "/p")).toBe(source);
} finally {
for (const path of [...otherPaths, source]) delete files[path];
}
});
it.each([false, true])(
"finds nonstandard package directories beyond the fuzzy limit (old remote: %s)",
async (legacy) => {
if (legacy)
useRemote.setState({
session: {
target: { kind: "wsl", distro: "Ubuntu" },
root: "/p",
host: "Ubuntu",
os: "linux",
commands: ["find_files", "index_status"],
},
});
const others = Array.from({ length: 65 }, (_, i) => `/p/other${i}/Orders.kt`);
const source = "/p/special/src/Orders.kt";
for (const [i, path] of others.entries()) files[path] = `package other${i}`;
files[source] = "package com.example.orders\nfun main() {}";
try {
expect(await resolveTarget(jvmFrameTarget("com.example.orders.OrdersKt", "Orders.kt", 1), "/p")).toBe(source);
} finally {
for (const path of [...others, source]) delete files[path];
}
},
);
it("library classes open the language server's source, else nothing", async () => {
expect(await resolveTarget(jvmFrameTarget("java.util.ArrayList", "ArrayList.java", 427), "/p")).toBeNull();
symbols.list = [
+13 -6
Переглянути файл
@@ -1,6 +1,7 @@
/** Opens file locations printed in run output (resolving relative and bare names). */
import { call, commands, isTauri, nextRequestId } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { useRemote } from "@/platform/remote";
import { editorService, notifications } from "@/workbench/contributions";
import { streamSearch } from "@/platform/textSearch";
import { joinPath, type FileTarget } from "./links";
@@ -36,18 +37,24 @@ async function declaresPackage(path: string, pkg: string): Promise<boolean> {
async function findByName(target: FileTarget): Promise<string | null> {
const name = target.path.split("/").pop() ?? target.path;
try {
const hits = await call(commands.findFiles(name, 50));
const remote = useRemote.getState().session;
// Older remote servers only have fuzzy search: request the whole inventory before filtering.
const hits =
remote && !remote.commands.includes("find_files_named")
? (await call(commands.findFiles(name, (await call(commands.indexStatus())).files)))
.filter((h) => h.relPath.split("/").pop() === name)
.map((h) => h.path)
: await call(commands.findFilesNamed(name));
const tail = target.path.replace(/^\.?\//, "");
const exact = hits.find((h) => h.relPath === tail || h.relPath.endsWith(`/${tail}`));
if (exact) return exact.path;
const named = hits.filter((h) => h.relPath.split("/").pop() === name);
const exact = hits.find((path) => path === tail || path.endsWith(`/${tail}`));
if (exact) return exact;
// A stack frame names its class: a same-named file of another package is not it.
if (target.className) {
const pkg = packageOf(target.className);
for (const h of named) if (await declaresPackage(h.path, pkg)) return h.path;
for (const path of hits) if (await declaresPackage(path, pkg)) return path;
return null;
}
return named[0]?.path ?? null;
return hits[0] ?? null;
} catch {
return null;
}