fix(run): offer Run for classes whose superclass declares main (#1204)

The JVM run-target scanner only reads the file's own text, so a class like `class App extends AtomStarter {}`, whose superclass (in the project or a library JAR) declares `static main`, got no gutter Run, no Ctrl+Shift+R and no file-level Run, although the JVM launches it. Classes with an `extends` clause and no main of their own are now candidates: the superclass chain is walked through the language server's type hierarchy, each class supertype's source (file, decompiled or source-jar document) is checked for a static main, and the answer is cached per class and `extends` clause, so the gutter, Run from context, launch and debug agree. Interfaces are skipped (a static interface main is not inherited); negative answers are asked again when the server settles. `main(java.lang.String[] a)` is accepted as well.

Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com>
This commit is contained in:
ntbang0901
2026-11-04 23:11:01 +08:00
committed by GitHub
parent 8f77d7077c
commit d032addb17
7 changed files with 431 additions and 9 deletions
+125
View File
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HierarchyItem } from "@/platform/ipc";
import {
clearInheritedMain,
hasInheritedMain,
resolveCandidates,
resolveInheritedMain,
unresolvedCandidates,
type InheritedMainDeps,
} from "./inheritedMain";
const item = (name: string, path: string, kind: HierarchyItem["kind"] = "class"): HierarchyItem => ({
name,
kind,
detail: null,
path,
range: { start: { line: 0, col: 0 }, end: { line: 5, col: 1 } },
selectionRange: { start: { line: 0, col: 6 }, end: { line: 0, col: 6 + name.length } },
fromRanges: [],
});
const MAIN = "public static void main(String[] a) {}";
function deps(supers: Record<string, HierarchyItem[] | Error>, texts: Record<string, string>) {
const d: InheritedMainDeps = {
supertypes: vi.fn(async (path) => {
const r = supers[path];
if (r instanceof Error) throw r;
return r ?? [];
}),
text: vi.fn(async (i) => {
const t = texts[i.uri ?? i.path];
if (t === undefined) throw new Error("unreadable");
return t;
}),
};
return d;
}
const at = { line: 3, col: 13 };
beforeEach(() => clearInheritedMain());
describe("resolveInheritedMain", () => {
it("finds main in a direct superclass inside a jar", async () => {
const jar = "jdt://contents/lib.jar/pkg/AtomStarter.class";
const d = deps(
{ "/p/App.java": [item("AtomStarter", jar)] },
{ [jar]: `package pkg; class AtomStarter { ${MAIN} }` },
);
expect(await resolveInheritedMain("/p/App.java", at, d)).toBe(true);
});
it("walks two levels", async () => {
const d = deps(
{ "/p/App.java": [item("Mid", "/p/Mid.java")], "/p/Mid.java": [item("Base", "/p/Base.java")] },
{ "/p/Mid.java": "class Mid extends Base {}", "/p/Base.java": `class Base { ${MAIN} }` },
);
expect(await resolveInheritedMain("/p/App.java", at, d)).toBe(true);
});
it("ignores interfaces and instance main", async () => {
const d = deps(
{ "/p/App.java": [item("Iface", "/p/Iface.java", "interface"), item("Inst", "/p/Inst.java")] },
{ "/p/Iface.java": `interface Iface { ${MAIN} }`, "/p/Inst.java": "class Inst { void main() {} }" },
);
expect(await resolveInheritedMain("/p/App.java", at, d)).toBe(false);
});
it("stops on cycles", async () => {
const d = deps(
{ "/p/A.java": [item("B", "/p/B.java")], "/p/B.java": [item("B", "/p/B.java")] },
{ "/p/B.java": "class B {}" },
);
expect(await resolveInheritedMain("/p/A.java", at, d)).toBe(false);
expect(d.supertypes).toHaveBeenCalledTimes(2);
});
it("is undefined when the server cannot answer, false when a deeper level fails", async () => {
expect(await resolveInheritedMain("/p/A.java", at, deps({ "/p/A.java": new Error("x") }, {}))).toBeUndefined();
const d = deps(
{ "/p/A.java": [item("B", "/p/B.java")], "/p/B.java": new Error("x") },
{ "/p/B.java": "class B {}" },
);
expect(await resolveInheritedMain("/p/A.java", at, d)).toBe(false);
});
it("treats an unreadable supertype as no main", async () => {
const d = deps({ "/p/A.java": [item("B", "/p/B.java")] }, {});
expect(await resolveInheritedMain("/p/A.java", at, d)).toBe(false);
});
it("reads each supertype's text once", async () => {
const d = deps(
{ "/p/A.java": [item("B", "/p/B.java")], "/p/C.java": [item("B", "/p/B.java")] },
{ "/p/B.java": "class B {}" },
);
await resolveInheritedMain("/p/A.java", at, d);
await resolveInheritedMain("/p/C.java", at, d);
expect(d.text).toHaveBeenCalledTimes(1);
});
});
describe("resolveCandidates", () => {
const src = "package demo;\n\npublic class App extends Base {}\n";
it("records answers for fileTargets and skips them next time", async () => {
const d = deps({ "/p/App.java": [item("Base", "/p/Base.java")] }, { "/p/Base.java": `class Base { ${MAIN} }` });
expect(unresolvedCandidates("/p/App.java", src)).toHaveLength(1);
expect(await resolveCandidates("/p/App.java", unresolvedCandidates("/p/App.java", src), d)).toBe(true);
expect(hasInheritedMain("/p/App.java", { className: "demo.App", base: "Base" })).toBe(true);
expect(unresolvedCandidates("/p/App.java", src)).toHaveLength(0);
});
it("asks again when the extends clause changes", async () => {
const d = deps({ "/p/App.java": [item("Base", "/p/Base.java")] }, { "/p/Base.java": `class Base { ${MAIN} }` });
await resolveCandidates("/p/App.java", unresolvedCandidates("/p/App.java", src), d);
const edited = src.replace("extends Base", "extends Other");
expect(hasInheritedMain("/p/App.java", { className: "demo.App", base: "Other" })).toBe(false);
expect(unresolvedCandidates("/p/App.java", edited)).toHaveLength(1);
});
it("leaves a candidate pending while the server is unavailable", async () => {
const d = deps({ "/p/App.java": new Error("unavailable") }, {});
expect(await resolveCandidates("/p/App.java", unresolvedCandidates("/p/App.java", src), d)).toBe(false);
expect(unresolvedCandidates("/p/App.java", src)).toHaveLength(1);
});
});
+142
View File
@@ -0,0 +1,142 @@
/**
* `main` inherited from a superclass (in the project or a library JAR): the JVM
* launches `java -cp … Sub` fine when `Base` declares `static main`, but the
* synchronous scanner only sees the file's own text. This asks the language
* server for the superclass chain once, and remembers the answer for
* `fileTargets` to read synchronously.
*/
import { lspRequest } from "@/editor/lsp/lsp";
import { readFileLines, withDocument } from "@/editor/lsp/files";
import { commands, type HierarchyItem, type Pos } from "@/platform/ipc";
import type { RunTarget } from "./targets";
import { jvmMainCandidates, scanJvmTargets, simpleClassName, type JvmMainCandidate } from "./jvmTargets";
export interface InheritedMainDeps {
supertypes: (path: string, pos: Pos) => Promise<HierarchyItem[]>;
/** Source text of a supertype (file, or decompiled / source-jar document). */
text: (item: HierarchyItem) => Promise<string>;
}
export const inheritedMainDeps: InheritedMainDeps = {
supertypes: (path, pos) =>
withDocument(path, (doc) => lspRequest(() => commands.lspTypeHierarchy(doc, pos, "incoming"))),
text: async (item) => {
const lines = await readFileLines(item.uri ?? item.path);
if (!lines) throw new Error("unreadable");
return lines.join("\n");
},
};
/** Whether the type declares a static `main` itself, by `uri ?? path` and name. */
const declares = new Map<string, Promise<boolean>>();
/** file → `class extends base` → inherits a static `main`. */
const resolved = new Map<string, Map<string, boolean>>();
const keyOf = (c: Pick<JvmMainCandidate, "className" | "base">) => `${c.className} extends ${c.base}`;
/** After the server settles: ask again where it said "no" (it may have been indexing); keep the "yes" answers. */
export function retryNegativeInheritedMain() {
declares.clear();
for (const m of resolved.values()) for (const [k, v] of m) if (!v) m.delete(k);
}
export function clearInheritedMain() {
declares.clear();
resolved.clear();
}
export const hasInheritedMain = (path: string, c: Pick<JvmMainCandidate, "className" | "base">) =>
resolved.get(path)?.get(keyOf(c)) === true;
/** Candidates of `path` not answered yet. */
export function unresolvedCandidates(path: string, text: string): JvmMainCandidate[] {
const done = resolved.get(path);
return jvmMainCandidates(text, path).filter((c) => !done?.has(keyOf(c)));
}
function declaresMain(s: HierarchyItem, deps: InheritedMainDeps): Promise<boolean> {
const key = `${s.uri ?? s.path}#${s.name}`;
let p = declares.get(key);
if (!p) {
const name = s.name.replace(/<.*$/, "");
p = deps
.text(s)
.then((text) =>
scanJvmTargets(text, "", false).some(
(t) => t.kind === "main" && !t.instance && simpleClassName(t.className) === name,
),
);
// Not remembered on failure: the document may become readable later.
p.catch(() => declares.delete(key));
declares.set(key, p);
}
return p.catch(() => false);
}
/**
* Whether a superclass of the type at `pos` declares a static `main`.
* `undefined` when the server could not say (not ready), so the caller retries later.
* Interfaces are skipped: a static interface `main` is not inherited.
*/
export async function resolveInheritedMain(
path: string,
pos: Pos,
deps: InheritedMainDeps,
depth = 6,
): Promise<boolean | undefined> {
const seen = new Set<string>();
let level = [{ path, pos }];
for (let d = 0; d < depth && level.length; d++) {
const next: typeof level = [];
for (const t of level) {
let supers: HierarchyItem[];
try {
supers = await deps.supertypes(t.path, t.pos);
} catch {
if (d === 0) return undefined;
continue;
}
for (const s of supers) {
if (s.kind !== "class") continue;
const key = `${s.uri ?? s.path}:${s.selectionRange.start.line}`;
if (seen.has(key)) continue;
seen.add(key);
if (await declaresMain(s, deps)) return true;
next.push({ path: s.path, pos: s.selectionRange.start });
}
}
level = next;
}
return false;
}
/** Resolves the file's pending candidates; true when a new inherited `main` was found. */
export async function resolveCandidates(
path: string,
candidates: readonly JvmMainCandidate[],
deps: InheritedMainDeps = inheritedMainDeps,
): Promise<boolean> {
let found = false;
for (const c of candidates) {
const has = await resolveInheritedMain(path, { line: c.line, col: c.col }, deps);
if (has === undefined) continue;
if (!resolved.has(path)) resolved.set(path, new Map());
resolved.get(path)!.set(keyOf(c), has);
found ||= has;
}
return found;
}
/** `main` targets for the file's classes whose superclass declares one (answers resolved earlier). */
export function inheritedRunTargets(path: string, text: string): RunTarget[] {
if (!resolved.get(path)?.size) return [];
return jvmMainCandidates(text, path)
.filter((c) => hasInheritedMain(path, c))
.map((c) => ({
line: c.line,
kind: "main",
name: simpleClassName(c.className),
path: [c.className],
className: c.className,
spring: c.spring,
}));
}
+33 -1
View File
@@ -11,7 +11,7 @@ import { useProject } from "@/platform/project";
import { applicationConfig, jvmModuleDir, jvmTestConfig } from "./application";
import { buildRows } from "./ConfigChooser";
import { jdwpLaunch } from "./javaDebug";
import { scanJvmTargets } from "./jvmTargets";
import { jvmMainCandidates, scanJvmTargets } from "./jvmTargets";
import { adoptTemporary, MAX_TEMPORARY, saveTemporary, testReportEvents, useRun } from "./store";
import { detectRunTargets, targetConfig } from "./targets";
@@ -436,3 +436,35 @@ describe("temporary configurations", () => {
expect(useRun.getState().temporary).toEqual([]);
});
});
describe("jvmMainCandidates", () => {
const names = (src: string) => jvmMainCandidates(src, "a/B.java").map((c) => c.className);
it("finds the class from the report, at its name", () => {
const src = "package demo;\n\n@SpringBootApplication\npublic class AiChatApplication extends AtomStarter {}\n";
expect(jvmMainCandidates(src, "a/B.java")).toEqual([
{ line: 3, col: 13, className: "demo.AiChatApplication", base: "AtomStarter", spring: true },
]);
});
it.each([
["static nested class", "class A { static class B extends Base {} }", ["A$B"]],
["inner (non-static) class", "class A { class B extends Base {} }", []],
["abstract class", "abstract class A extends Base {}", ["A"]],
["own main", "class A extends Base { public static void main(String[] a) {} }", []],
["implements only", "class A implements Runnable {}", []],
["interface extends", "interface A extends Base {}", []],
["enum and record", "enum E implements I {}\nrecord R() implements I {}", []],
["multi-line generic header", "class A<T extends Number>\n extends Base<T>\n{}", ["A"]],
["type bound only", "class A<T extends Number> {}", []],
["extends in comment and string", '// class A extends B\nclass C { String s = "class D extends E {"; }', []],
])("%s", (_, src, expected) => expect(names(src)).toEqual(expected));
it("accepts java.lang.String[] and flags instance main", () => {
const [fq] = scanJvmTargets("class A { public static void main(java.lang.String[] a) {} }", "A.java", false);
expect(fq).toMatchObject({ kind: "main", className: "A" });
const [inst] = scanJvmTargets("class A { void main() {} }", "A.java", false);
expect(inst).toMatchObject({ kind: "main", instance: true });
expect(fq?.instance).toBeUndefined();
});
});
+70 -4
View File
@@ -17,11 +17,25 @@ export interface JvmTarget {
method?: string;
/** The class is a `@SpringBootApplication`. */
spring?: boolean;
/** Java 25 instance `main` (JEP 512): a subclass cannot inherit it on older JDKs. */
instance?: boolean;
}
/** A class with `extends X` and no `main` of its own: it may inherit one. */
export interface JvmMainCandidate {
/** Position of the class name (0-based line, UTF-16 column). */
line: number;
col: number;
className: string;
/** The superclass as written (`a.b.Base`), so an answer lapses when the `extends` clause changes. */
base: string;
spring?: boolean;
}
interface Token {
text: string;
line: number;
col: number;
}
/** Blanks comments, strings, text blocks and char literals (newlines kept). */
@@ -69,7 +83,7 @@ function tokenize(code: string): Token[] {
for (let line = 0; line < lines.length; line++) {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(lines[line]!))) tokens.push({ text: m[0], line });
while ((m = re.exec(lines[line]!))) tokens.push({ text: m[0], line, col: m.index });
}
return tokens;
}
@@ -90,15 +104,27 @@ interface ClassFrame {
annotations: string[];
kind: string;
companion: boolean;
/** Java `class … extends X` that may inherit a `main` (top-level or `static` nested). */
inheritsFrom?: { col: number; base: string };
}
/** Targets of a Java (`kotlin = false`) or Kotlin source file. */
export function scanJvmTargets(src: string, relPath: string, kotlin: boolean): JvmTarget[] {
return scan(src, relPath, kotlin).targets;
}
/** Java classes that extend another class but declare no `main`; see `inheritedMain.ts`. */
export function jvmMainCandidates(src: string, relPath: string): JvmMainCandidate[] {
return scan(src, relPath, false).candidates;
}
function scan(src: string, relPath: string, kotlin: boolean) {
const code = stripCommentsAndStrings(src);
const pkg = /^\s*package\s+([\w.]+)/m.exec(code)?.[1] ?? "";
const qualify = (binary: string) => (pkg ? `${pkg}.${binary}` : binary);
const tokens = tokenize(code);
const out: JvmTarget[] = [];
const candidates: JvmMainCandidate[] = [];
const classes: ClassFrame[] = [];
/** A class header seen, waiting for its `{`. */
let pendingClass: Omit<ClassFrame, "depth"> | null = null;
@@ -151,6 +177,9 @@ export function scanJvmTargets(src: string, relPath: string, kotlin: boolean): J
const name = isIdent(next) && !TYPE_KEYWORDS.has(next!) ? unquote(next!) : companion ? "Companion" : null;
if (name) {
pendingClass = { name, line: tokens[i]!.line, annotations, kind: t, companion };
const base = !kotlin && t === "class" ? extendsClause(tokens, i) : null;
if (base && (classes.length === 0 || hasModifier(tokens, i, "static")))
pendingClass.inheritsFrom = { col: tokens[i + 1]!.col, base };
annotations = [];
}
continue;
@@ -158,7 +187,16 @@ export function scanJvmTargets(src: string, relPath: string, kotlin: boolean): J
if (t === "{") {
if (pendingClass) {
classes.push({ ...pendingClass, depth: depth + 1 });
const frame = { ...pendingClass, depth: depth + 1 };
if (frame.inheritsFrom)
candidates.push({
line: frame.line,
col: frame.inheritsFrom.col,
className: qualify(binaryName([...classes, frame])),
base: frame.inheritsFrom.base,
spring: frame.annotations.includes("SpringBootApplication") || undefined,
});
classes.push(frame);
pendingClass = null;
}
depth++;
@@ -223,7 +261,8 @@ export function scanJvmTargets(src: string, relPath: string, kotlin: boolean): J
}
} else if (name === "main") {
const paramText = params.filter((p) => p !== "final").join(" ");
const stringArgs = /^String\s*(\[\s*]|\.\s*\.\s*\.)/.test(paramText) || paramText === "";
const stringArgs =
/^(java\s*\.\s*lang\s*\.\s*)?String\s*(\[\s*]|\.\s*\.\s*\.)/.test(paramText) || paramText === "";
const kotlinArgs = /^\w+\s*:\s*Array\s*<\s*(out\s+)?String\s*>$/.test(paramText) || paramText === "";
if (kotlin && kotlinArgs) {
if (!owner) {
@@ -254,6 +293,7 @@ export function scanJvmTargets(src: string, relPath: string, kotlin: boolean): J
kind: "main",
className: qualify(binaryName(frames)),
spring: spring || undefined,
instance: !isStatic || undefined,
});
}
}
@@ -269,7 +309,33 @@ export function scanJvmTargets(src: string, relPath: string, kotlin: boolean): J
// One main per class is enough (overloads).
const seen = new Set<string>();
const unique = out.filter((t) => t.kind !== "main" || (!seen.has(t.className) && !!seen.add(t.className)));
return [...suites, ...unique].sort((a, b) => a.line - b.line);
const own = new Set(unique.filter((t) => t.kind === "main").map((t) => t.className));
return {
targets: [...suites, ...unique].sort((a, b) => a.line - b.line),
candidates: candidates.filter((c) => !own.has(c.className)),
};
}
function hasModifier(tokens: Token[], at: number, modifier: string): boolean {
for (let k = at - 1; k >= 0 && !MEMBER_END.has(tokens[k]!.text); k--) if (tokens[k]!.text === modifier) return true;
return false;
}
/** The superclass named by the `extends` clause of the `class` at `at` (not a type-parameter bound), if any. */
function extendsClause(tokens: Token[], at: number): string | null {
let angle = 0;
for (let k = at + 2; k < tokens.length && tokens[k]!.text !== "{" && tokens[k]!.text !== ";"; k++) {
const t = tokens[k]!.text;
if (t === "<") angle++;
else if (t === ">") angle--;
else if (angle === 0 && t === "extends") {
let base = "";
for (let j = k + 1; j < tokens.length && !["<", "{", "implements", "permits"].includes(tokens[j]!.text); j++)
base += tokens[j]!.text;
return base || null;
}
}
return null;
}
/** Simple name of a binary class name (`a.b.Outer$Inner` → `Inner`). */
+29 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import { afterEach, describe, expect, it } from "vitest";
import type { HierarchyItem, RunConfig } from "@/platform/ipc";
import {
innermostTarget,
nodeTypeStripping,
@@ -15,8 +15,18 @@ import {
type LocatedTool,
type ToolEnv,
} from "./producers";
import { clearInheritedMain, resolveCandidates, unresolvedCandidates } from "./inheritedMain";
afterEach(() => clearInheritedMain());
const ROOT = "/work/app";
const superItem: Omit<HierarchyItem, "name" | "path"> = {
kind: "class",
detail: null,
range: { start: { line: 0, col: 0 }, end: { line: 1, col: 0 } },
selectionRange: { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } },
fromRanges: [],
};
const SCRATCH = "/Users/me/Library/Application Support/ai.l0ng.openidea/scratches";
const cfg = (over: Partial<RunConfig> & { id: string; kind: string }): RunConfig => ({
@@ -331,6 +341,23 @@ describe("single-file producers (scratches)", () => {
expect(p.config!.args).toEqual(["-cp", "${classpath}", "com.example.ShopApplication"]);
});
it("Java: a class whose superclass declares main runs as a Spring Boot application", async () => {
const path = `${ROOT}/src/main/java/demo/AiChatApplication.java`;
const content = "package demo;\n\n@SpringBootApplication\npublic class AiChatApplication extends AtomStarter {}\n";
const gradle = [cfg({ id: "gradle:test", kind: "gradle", command: "./gradlew", args: ["test"], isTest: true })];
const input = { path, root: ROOT, configs: gradle, content, line: 3 };
expect(fileTargets(input, content)).toEqual([]);
await resolveCandidates(path, unresolvedCandidates(path, content), {
supertypes: async () => [{ ...superItem, name: "AtomStarter", path: "jdt://lib/AtomStarter.class" }],
text: async () => "package x; public class AtomStarter { public static void main(String[] a) {} }",
});
expect(fileTargets(input, content)).toMatchObject([
{ kind: "main", className: "demo.AiChatApplication", spring: true },
]);
expect(produceContext(input, ALL)!.config).toMatchObject({ kind: "spring-boot" });
expect(produceContext(input, ALL)!.config!.args.at(-1)).toBe("demo.AiChatApplication");
});
describe("Java / Kotlin mains in a build nested in a folder of repositories", () => {
// RuoYi-Vue-Plus (Maven) next to plus-ui (npm), opened together.
const boot =
+7 -2
View File
@@ -19,6 +19,7 @@ import { goTestParts } from "./goArguments";
import { contextConfig, fileRole, projectPython, testBasesFor, testConfigFor } from "./context";
import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes";
import { isWindows } from "./jvmTypes";
import { inheritedRunTargets } from "./inheritedMain";
import { detectRunTargets, targetConfig, targetLanguage, type RunTarget } from "./targets";
import { tx } from "@/platform/i18n";
@@ -636,7 +637,10 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
}
if (/\.(java|kt)$/.test(rel) && hasBuildTool(rel, configs, JVM_BUILDS)) {
if (input.content === undefined) return null;
const targets = detectRunTargets(input.content, rel).filter((t) => t.className);
const targets = [
...detectRunTargets(input.content, rel),
...inheritedRunTargets(input.path, input.content),
].filter((t) => t.className);
const target = targets.find((t) => t.kind === "main") ?? targets.find((t) => t.kind === "suite");
const config = target ? targetConfig(target, rel, configs, input.content) : null;
return config ? { name: config.name, scope: "file", config } : null;
@@ -839,7 +843,8 @@ export function fileTargets(
(t) => t.kind === "main" && (!t.className || /\.(java|kt)$/.test(input.path)),
);
}
return detectRunTargets(content, s.rel, hasDjangoTests(input.configs));
const targets = detectRunTargets(content, s.rel, hasDjangoTests(input.configs));
return /\.java$/.test(input.path) ? [...targets, ...inheritedRunTargets(input.path, content)] : targets;
}
function elementProduction(input: ContextInput, env: ToolEnv, target: RunTarget): Produced | null {
+25
View File
@@ -21,6 +21,8 @@ import {
projectContext,
} from "./contextRun";
import { loadGoModules } from "./goModules";
import { onServerSettled } from "@/editor/lsp/availability";
import { resolveCandidates, retryNegativeInheritedMain, unresolvedCandidates } from "./inheritedMain";
import { fileTargets, OPTIMISTIC, produceContextChoices, type ContextInput } from "./producers";
import { loadConfigs, useRun } from "./store";
import { targetLanguage, type RunTarget } from "./targets";
@@ -203,8 +205,15 @@ export function runGutter(ctx: EditorDocContext): Extension {
class {
timer: ReturnType<typeof setTimeout> | undefined;
readonly unsubscribe: () => void;
readonly offServers: () => void;
resolving = false;
destroyed = false;
constructor(readonly view: EditorView) {
this.schedule(0);
this.offServers = onServerSettled(() => {
retryNegativeInheritedMain();
this.schedule(0);
});
// Redraw the markers with the new results (the gutter recomputes them on any update).
this.unsubscribe = onTestStatesChanged(() => {
if (view.state.field(targetsField).length) view.dispatch({ effects: testStatesChanged.of(null) });
@@ -223,10 +232,26 @@ export function runGutter(ctx: EditorDocContext): Extension {
cur.length === found.length &&
cur.every((t, i) => t.pos === found[i]!.pos && t.name === found[i]!.name && t.kind === found[i]!.kind);
if (!same) view.dispatch({ effects: setTargets.of(found) });
this.resolveInherited(view.state.doc.toString());
}, delay);
}
/** Classes that may inherit a `main`: ask the language server, then redraw if one does. */
resolveInherited(text: string) {
if (this.resolving || !path.endsWith(".java")) return;
const pending = unresolvedCandidates(path, text);
if (!pending.length) return;
this.resolving = true;
resolveCandidates(path, pending)
.then((found) => {
if (found && !this.destroyed) this.schedule(0);
})
.catch(() => {})
.finally(() => (this.resolving = false));
}
destroy() {
clearTimeout(this.timer);
this.destroyed = true;
this.offServers();
this.unsubscribe();
}
},