mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`). */
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user