From d032addb17ad774eef2373563fe22494c87b966d Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:11:01 +0700 Subject: [PATCH] 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> --- src/features/run/inheritedMain.test.ts | 125 ++++++++++++++++++++++ src/features/run/inheritedMain.ts | 142 +++++++++++++++++++++++++ src/features/run/java.test.ts | 34 +++++- src/features/run/jvmTargets.ts | 74 ++++++++++++- src/features/run/producers.test.ts | 31 +++++- src/features/run/producers.ts | 9 +- src/features/run/runGutter.ts | 25 +++++ 7 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 src/features/run/inheritedMain.test.ts create mode 100644 src/features/run/inheritedMain.ts diff --git a/src/features/run/inheritedMain.test.ts b/src/features/run/inheritedMain.test.ts new file mode 100644 index 00000000..4043e462 --- /dev/null +++ b/src/features/run/inheritedMain.test.ts @@ -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, texts: Record) { + 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); + }); +}); diff --git a/src/features/run/inheritedMain.ts b/src/features/run/inheritedMain.ts new file mode 100644 index 00000000..a6261984 --- /dev/null +++ b/src/features/run/inheritedMain.ts @@ -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; + /** Source text of a supertype (file, or decompiled / source-jar document). */ + text: (item: HierarchyItem) => Promise; +} + +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>(); +/** file → `class extends base` → inherits a static `main`. */ +const resolved = new Map>(); +const keyOf = (c: Pick) => `${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) => + 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 { + 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 { + const seen = new Set(); + 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 { + 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, + })); +} diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 1ee300d7..d48d0151 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -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\n extends Base\n{}", ["A"]], + ["type bound only", "class A {}", []], + ["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(); + }); +}); diff --git a/src/features/run/jvmTargets.ts b/src/features/run/jvmTargets.ts index 4635793c..63df4b6c 100644 --- a/src/features/run/jvmTargets.ts +++ b/src/features/run/jvmTargets.ts @@ -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 | 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(); 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`). */ diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index 4e73b758..00299b4c 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -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 = { + 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 & { 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 = diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index efed66bd..c0cb58d2 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -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 { diff --git a/src/features/run/runGutter.ts b/src/features/run/runGutter.ts index 0fe0e8b6..9d630c96 100644 --- a/src/features/run/runGutter.ts +++ b/src/features/run/runGutter.ts @@ -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 | 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(); } },