feat(gradle): import modules from Gradle itself (includeFlat, included builds, computed includes) (#715)

The Gradle model came from a static parse of settings.gradle, which cannot follow includeFlat, projectDir assignments, computed include lists or included builds, so those modules were missing from the Gradle tool window, the Project view, indexing and run. Sync now runs an init script that prints every evaluated project (identity path, directory, build directory, source sets with test classification, Java version) without resolving dependencies; the static parser stays as the fallback before the first sync. Modules outside the root become content roots, the Project view lists the build's module group above the project folder, an unsynced Gradle build is imported once when opened, and staleness ignores comments and whitespace in build scripts.

Application / Spring Boot configurations of a Gradle module run through a JavaExec task on the source set's runtime classpath by default (Settings › Build Tools › Gradle › Build and run using), with the debugger attached to the forked JVM. The build model reaches run and the Project view through a platform store rather than a cross-feature import.

Fixes #737
Fixes #861

Co-authored-by: ntbang0901 <[email protected]>
Цей коміт міститься в:
ntbang0901 authored and GitHub committed 2026-11-04 01:09:07 +08:00
1 джерело 0c1a64bc2c
коміт bd424a3805
34 files changed
+1454 -110

No files matched your search

+2 -1
Переглянути файл
@@ -43,7 +43,8 @@ export const MAVEN_WHEN = "projectOpen && build.maven";
export const GRADLE_WHEN = "projectOpen && build.gradle";
/** Build files whose creation / change / removal re-reads the model. */
const BUILD_FILE = /(?:^|\/)(?:pom\.xml|(?:build|settings)\.gradle(?:\.kts)?|mvnw(?:\.cmd)?|gradlew(?:\.bat)?)$/;
const BUILD_FILE =
/(?:^|\/)(?:pom\.xml|(?:build|settings)\.gradle(?:\.kts)?|mvnw(?:\.cmd)?|gradlew(?:\.bat)?|gradle\.properties|gradle-wrapper\.properties|[^/]+\.versions\.toml)$/;
function expandAll(tool: BuildTool) {
const { model, configs } = useBuild.getState();
+17
Переглянути файл
@@ -23,6 +23,7 @@ import {
type SyncProgress,
} from "@/platform/ipc";
import { downloadJdkActions } from "@/platform/jdk";
import { useBuildModel } from "@/platform/buildModel";
import { useProject } from "@/platform/project";
import { editorService, notifications } from "@/workbench/contributions";
import { cancelTask, removeTask, upsertTask, useTasks } from "@/workbench/tasks";
@@ -74,6 +75,14 @@ export const useBuild = create<BuildState>(() => ({
current: {},
}));
// Shared with the features that follow the build (run, Project view).
useBuild.subscribe((s) => {
const shared = useBuildModel.getState();
const gradleOffline = s.settings.gradle.offline;
if (shared.root !== s.root || shared.model !== s.model || shared.gradleOffline !== gradleOffline)
useBuildModel.setState({ root: s.root, model: s.model, gradleOffline });
});
// ---------------------------------------------------------------------------
// Settings (per project)
@@ -189,6 +198,9 @@ export function changedOutsideIde(paths: readonly string[], now = Date.now()): b
return external;
}
/** Root whose never-synced Gradle build was imported on open (once per open). */
let autoImported: string | null = null;
/** Re-reads the build model (and the run configurations) of the open project. */
export async function loadModel(): Promise<void> {
const root = useProject.getState().project?.root ?? null;
@@ -218,6 +230,11 @@ export async function loadModel(): Promise<void> {
const maven = model.maven;
if (maven?.stale && maven.syncedAt !== null && !useBuild.getState().syncing.maven) void syncTool("maven");
}
// Like IDEA, a Gradle build is imported when first opened; later changes wait for Reload.
if (model.gradle && model.gradle.syncedAt === null && autoImported !== root) {
autoImported = root;
void syncTool("gradle");
}
} catch (e) {
if (gen === generation) useBuild.setState({ loading: false, error: e instanceof Error ? e.message : String(e) });
}
+3 -1
Переглянути файл
@@ -132,7 +132,9 @@ function iconFor(
const kind = folderKind(node, roots, root, build, detected);
if (kind !== "plain") return folderIconName(kind);
const rel = root ? (node.path === root ? "" : relativePath(root, node.path)) : null;
const module = rel === null ? null : moduleFolderIcon(rel, node.root, build.modules);
// Modules outside the project are keyed by their absolute folder.
const key = rel !== null && build.modules.has(rel) ? rel : node.path;
const module = root ? moduleFolderIcon(key, node.root, build.modules) : null;
if (module) return module;
return node.root ? "module" : "folder";
}
+19 -4
Переглянути файл
@@ -1,6 +1,7 @@
/** Project view: the "project" tool window, its file operations and actions. */
import { onEditorEvent, openEditorPaths } from "@/editor/api";
import { onIdeEvent } from "@/platform/ipc";
import { useBuildModel } from "@/platform/buildModel";
import { validateProjectStructure, type RootFolder } from "@/platform/languageSettings";
import { useProject } from "@/platform/project";
import { initProjectRoots, useProjectRoots } from "@/platform/projectRoots";
@@ -15,6 +16,7 @@ import {
useProjectViewRoots,
} from "@/workbench/contributions";
import { registerProjectActions } from "./actions";
import { gradleModuleGroups } from "./moduleGroups";
import { FileRefactorDialogHost } from "./FileRefactorDialogs";
import { NewPopupHost } from "./NewPopup";
import { RefactoringPreviewHost } from "./RefactoringPreview";
@@ -65,14 +67,20 @@ export function activate(): () => void {
if (s.root && s.root === prev.root && detectedExcluded(s) !== detectedExcluded(prev)) void reloadAll();
});
// Project Structure content roots besides the project folder, listed after it.
// Project Structure content roots after the project folder, and IDEA's module groups of
// a Gradle build with modules outside it (as of the last sync) before it.
let contentRoots: Array<() => void> = [];
let shownRoots = "";
const syncContentRoots = () => {
const { project, settings } = useProject.getState();
const paths =
project && !project.workspace ? validateProjectStructure(settings?.projectStructure).contentRoots : [];
const key = JSON.stringify(paths);
const own = project && !project.workspace;
const paths = own ? validateProjectStructure(settings?.projectStructure).contentRoots : [];
// The Build tool window's model (one load per build change), not a load of our own.
const build = useBuildModel.getState();
const gradleProjects = build.root === project?.root ? (build.model?.gradle?.projects ?? []) : [];
const rootName = gradleProjects.find((p) => p.path === ":")?.name ?? "";
const groups = own && project ? gradleModuleGroups(gradleProjects, rootName, project.root) : [];
const key = JSON.stringify([paths, groups]);
if (key === shownRoots) return;
shownRoots = key;
contentRoots.forEach((d) => d());
@@ -86,8 +94,14 @@ export function activate(): () => void {
order: -100 + i,
}),
);
groups.forEach((g, i) =>
contentRoots.push(registerProjectViewRoot({ ...g, id: g.id!, order: -200 + i, beforeProject: true })),
);
};
syncContentRoots();
const unsubGradleRoots = useBuildModel.subscribe((s, prev) => {
if (s.model !== prev.model || s.root !== prev.root) syncContentRoots();
});
const unsubContentRoots = useProject.subscribe((s, prev) => {
if (s.settings !== prev.settings || s.project !== prev.project) syncContentRoots();
});
@@ -124,6 +138,7 @@ export function activate(): () => void {
unsubExtraRoots,
unsubExcludes,
unsubContentRoots,
unsubGradleRoots,
() => contentRoots.forEach((d) => d()),
disposeRoots,
unsubRoots,
+16 -3
Переглянути файл
@@ -249,6 +249,8 @@ export interface ExtraRoot {
children?: readonly ExtraRoot[];
detail?: string;
readOnly?: boolean;
/** Listed above the project folder (IDEA's module group of the build). */
beforeProject?: boolean;
}
/** Key prefix of group nodes that show no folder ("External Libraries", an SDK). */
@@ -387,13 +389,24 @@ export function withExtraRoots(
readOnly: true,
};
out.byKey.set(key, node);
const kids = (extra.children ?? []).map((c) => add(c, key, ro)).filter((n): n is ProjectNode => !!n);
out.children.set(key, kids);
// Deepest folders first: a module folder nested in a sibling's (`billing/modules/core`
// in `billing`) would otherwise already be keyed by the sibling's listing and dropped.
const children = extra.children ?? [];
const depth = (c: ExtraRoot) => c.path?.split("/").length ?? 0;
const built: (ProjectNode | null)[] = [];
for (const i of children.map((_, i) => i).sort((a, b) => depth(children[b]!) - depth(children[a]!)))
built[i] = add(children[i]!, key, ro);
out.children.set(
key,
built.filter((n): n is ProjectNode => !!n),
);
return node;
};
let before = 0;
for (const extra of extras) {
const node = add(extra, null, false);
if (node) out.roots.push(node);
if (node && extra.beforeProject) out.roots.splice(before++, 0, node);
else if (node) out.roots.push(node);
}
return out;
}
+125
Переглянути файл
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import type { FileEntry } from "@/bindings";
import { buildModel, DEFAULT_TREE_OPTIONS, type ExtraRoot, withExtraRoots } from "./model";
import { gradleModuleGroups } from "./moduleGroups";
type Shown = { label: string; detail?: string; children?: Shown[] };
const shape = (e: ExtraRoot): Shown => ({
label: e.label,
...(e.detail ? { detail: e.detail } : {}),
...(e.children ? { children: e.children.map(shape) } : {}),
});
describe("gradleModuleGroups (IDEA's module groups)", () => {
// `includeFlat` sibling modules, one with layer modules nested in its folder.
const flat = [
{ path: ":", dir: "" },
{ path: ":common", dir: "/w/common" },
{ path: ":billing", dir: "/w/billing" },
...["core", "api"].map((l) => ({ path: `:billing:${l}`, dir: `/w/billing/modules/${l}` })),
];
it("lists every module of the group, nested ones flattened, as IntelliJ shows it", () => {
expect(gradleModuleGroups(flat, "Shop", "/w/shop").map(shape)).toEqual([
{
label: "Shop",
children: [
{ label: "api", detail: "Shop.billing.api" },
{ label: "billing", detail: "Shop.billing" },
{ label: "common", detail: "Shop.common" },
{ label: "core", detail: "Shop.billing.core" },
],
},
]);
});
it("shows no group while every module lives in the project folder", () => {
const inside = [
{ path: ":", dir: "" },
{ path: ":app", dir: "app" },
{ path: ":libs:core", dir: "libs/core" },
];
expect(gradleModuleGroups(inside, "demo", "/w/demo")).toEqual([]);
});
it("keeps a subgroup that no module is named like", () => {
const projects = [
{ path: ":", dir: "" },
{ path: ":svc", dir: "/w/svc" },
{ path: ":libs:a", dir: "/w/libs/a" },
{ path: ":libs:b", dir: "/w/libs/b" },
];
expect(gradleModuleGroups(projects, "R", "/w/r").map(shape)).toEqual([
{
label: "R",
children: [
{
label: "libs",
children: [
{ label: "a", detail: "R.libs.a" },
{ label: "b", detail: "R.libs.b" },
],
},
{ label: "svc", detail: "R.svc" },
],
},
]);
});
it("names an included build's modules after it and lists it as an ungrouped module", () => {
const projects = [
{ path: ":", dir: "" },
{ path: ":app", dir: "app" },
{ path: ":buildSrc", dir: "buildSrc", build: ":buildSrc" },
{ path: ":lib", dir: "/w/lib", build: ":lib" },
{ path: ":lib:core", dir: "/w/lib/core", build: ":lib" },
];
expect(gradleModuleGroups(projects, "Shop", "/w/shop").map(shape)).toEqual([{ label: "lib" }]);
// Beside includeFlat modules: the group (every `Shop.*` module), then the included build.
const both = [...projects, { path: ":svc", dir: "/w/svc" }];
expect(gradleModuleGroups(both, "Shop", "/w/shop").map(shape)).toEqual([
{
label: "Shop",
children: [
{ label: "app", detail: "Shop.app" },
{ label: "buildSrc", detail: "Shop.buildSrc" },
{ label: "svc", detail: "Shop.svc" },
],
},
{ label: "lib" },
]);
});
});
const dir = (path: string): FileEntry => ({
name: path.slice(path.lastIndexOf("/") + 1),
path,
kind: "dir",
size: 0,
mtimeMs: 1,
ignored: false,
excluded: false,
hidden: false,
});
describe("module group nodes in the tree", () => {
it("lists the group above the project and keeps a nested module whose parent is expanded", () => {
const listings = new Map([
["/w/billing", [dir("/w/billing/modules")]],
["/w/billing/modules", [dir("/w/billing/modules/core")]],
]);
const base = buildModel({ path: "/p", name: "p" }, new Map(), DEFAULT_TREE_OPTIONS);
const group = {
label: "Shop",
icon: "gradle-project",
beforeProject: true,
children: [
{ label: "billing", icon: "gradle-module", path: "/w/billing" },
{ label: "core", icon: "gradle-module", path: "/w/billing/modules/core" },
],
};
const m = withExtraRoots(base, [group], listings, DEFAULT_TREE_OPTIONS);
expect(m.roots.map((r) => r.label)).toEqual(["Shop", "p"]);
expect(m.children.get(m.roots[0]!.key)!.map((n) => n.label)).toEqual(["billing", "core"]);
});
});
+92
Переглянути файл
@@ -0,0 +1,92 @@
/**
* IDEA's module groups in the Project view, for Gradle builds whose modules live outside
* the project folder (`includeFlat`, computed `projectDir`s, `includeBuild`). Mirrors IntelliJ's
* `AbstractProjectNode.modulesAndGroups`, `ModuleGroup.modulesInGroup` / `childGroups`
* and the `QualifiedNameGrouper` of qualified module names (`<root project>.billing.core`).
*/
import type { ExtraRoot } from "./model";
import { isSameOrInside, nameOf } from "./paths";
interface Module {
/** Qualified name: `<root project>.<path segments>`. */
name: string;
dir: string;
}
const isAbsolute = (p: string) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
const segments = (name: string) => name.split(".");
const groupOf = (m: Module) => segments(m.name).slice(0, -1);
const same = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((x, i) => x === b[i]);
const isChild = (parent: readonly string[], d: readonly string[]) =>
d.length > parent.length && same(d.slice(0, parent.length), parent);
const byLabel = (a: ExtraRoot, b: ExtraRoot) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" });
/**
* Group nodes to list beside the project folder: none while the project folder is the only
* top-level module root (every module inside it), as in IDEA.
*/
export function gradleModuleGroups(
projects: readonly { path: string; dir: string; build?: string | null }[],
rootName: string,
projectRoot: string,
): ExtraRoot[] {
const modules: Module[] = projects.map((p) => {
const path = p.path.split(":").filter(Boolean);
// An included build's modules are named after that build (`lib.core`); buildSrc and
// the opened build's after the root project (`shop.buildSrc`), as IDEA names them.
const own = !p.build || p.build === ":buildSrc";
return {
name: (own ? [rootName, ...path] : path).join("."),
dir: isAbsolute(p.dir) ? p.dir : p.dir ? `${projectRoot}/${p.dir}` : projectRoot,
};
});
// A content root is top level when no other content root contains it (`isTopLevelRoot`).
const roots = [projectRoot, ...modules.map((m) => m.dir)];
const topLevel = modules.filter(
(m) => m.dir === projectRoot || !roots.some((r) => r !== m.dir && isSameOrInside(m.dir, r)),
);
if (topLevel.length <= 1) return [];
const asGroups = modules.map((m) => segments(m.name));
const isModulePath = (path: readonly string[]) => asGroups.some((g) => same(g, path));
const moduleNode = (m: Module): ExtraRoot => {
const label = nameOf(m.dir) || m.dir;
return { id: m.name, label, detail: m.name === label ? undefined : m.name, icon: "gradle-module", path: m.dir };
};
// `modulesAndGroups`: ungrouped top-level modules as they are (the project folder is
// shown anyway), one node for the common group path of the grouped ones.
const loose = topLevel.filter((m) => !groupOf(m).length && m.dir !== projectRoot).map(moduleNode);
const grouped = topLevel.map(groupOf).filter((g) => g.length);
if (!grouped.length) return loose.sort(byLabel);
let common = grouped[0]!;
for (const g of grouped.slice(1)) {
let n = 0;
while (n < Math.min(common.length, g.length) && common[n] === g[n]) n++;
common = common.slice(0, n);
}
const tops = common.length ? [common] : [...new Map(grouped.map((g) => [g[0]!, [g[0]!]])).values()];
const node = (group: readonly string[]): ExtraRoot => {
// `childGroups`: direct subgroups not named like a module (those fold into this group).
const childGroups = new Map<string, string[]>();
for (const m of modules) {
const g = groupOf(m);
const direct = [...group, g[group.length]!];
if (isChild(group, g) && !isModulePath(direct)) childGroups.set(direct.join("."), direct);
}
// `modulesInGroup`: this group's modules, and those under a subgroup named like a module.
const members = modules.filter((m) => {
const g = groupOf(m);
return same(g, group) || (isChild(group, g) && isModulePath(g.slice(0, group.length + 1)));
});
const seen = new Set<string>();
const moduleNodes = members.filter((m) => !seen.has(m.dir) && !!seen.add(m.dir)).map(moduleNode);
return {
id: `gradleGroup:${group.join(".")}`,
label: group[group.length - 1]!,
icon: "gradle-project",
children: [...[...childGroups.values()].map(node).sort(byLabel), ...moduleNodes.sort(byLabel)],
};
};
return [...tops.map(node), ...loose.sort(byLabel)];
}
+10 -3
Переглянути файл
@@ -5,14 +5,21 @@
*/
import type { RunConfig } from "@/platform/ipc";
import { CLASSPATH, TEST_CLASSPATH, jvmModuleDir } from "@/platform/javaLaunch";
import { isTestPath } from "@/platform/languageSettings";
import { isTestPath, rootFor } from "@/platform/languageSettings";
import { currentRoots } from "@/platform/projectRoots";
import { simpleClassName } from "./jvmTargets";
export { jvmModuleDir } from "@/platform/javaLaunch";
/** In a test source root (detected or marked in Project Structure), or Maven's `src/test`. */
const isTestSource = (relPath: string) => /(^|\/)src\/test\//.test(relPath) || isTestPath(currentRoots(), relPath);
/**
* In a test source root, as IDEA's `getClasspathType` decides: the root holding the file
* (synced from Gradle, detected or marked in Project Structure) wins; `src/test` only
* while no root is known for the file.
*/
const isTestSource = (relPath: string) => {
const roots = currentRoots();
return rootFor(roots, relPath) ? isTestPath(roots, relPath) : /(^|\/)src\/test\//.test(relPath);
};
/** Temporary "Application" configuration that runs `className` (a binary name). */
export function applicationConfig(
+32 -10
Переглянути файл
@@ -8,6 +8,8 @@ import { activeEditor } from "@/editor/api";
import { executeAction, getAction } from "@/platform/actions";
import { call, commands, isTauri, type RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { validateProjectStructure } from "@/platform/languageSettings";
import { useBuildModel } from "@/platform/buildModel";
import { isVirtualUri } from "@/platform/virtualDocs";
import { showContextMenu, type MenuItem } from "@/ui";
import {
@@ -42,6 +44,31 @@ async function ensureConfigs() {
}
/** The focused editor's file, text and caret line. */
/** Project folders outside its root: Project Structure content roots and Gradle modules (as of the last sync). */
export function projectContentRoots(): string[] {
const own = validateProjectStructure(useProject.getState().settings?.projectStructure).contentRoots;
// The build model keeps the folders of modules outside the project absolute.
const gradle = (useBuildModel.getState().model?.gradle?.projects ?? [])
.map((p) => p.dir)
.filter((d) => isAbsolute(d));
return [...new Set([...own, ...gradle])];
}
const isAbsolute = (p: string) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
/**
* The project side of every run input (editor, gutter, menus, actions): built in one place
* so no entry point runs a project file as a stray one.
*/
export function projectContext(): Pick<ContextInput, "root" | "configs" | "goModule" | "contentRoots"> {
return {
root: useProject.getState().project?.root ?? null,
configs: useRun.getState().configs,
goModule: goModulePath,
contentRoots: projectContentRoots(),
};
}
export function editorInput(): ContextInput | null {
const path = maybeEditorService()?.activePath() ?? activeEditor()?.ctx.path ?? null;
if (!path || isVirtualUri(path)) return null;
@@ -49,12 +76,10 @@ export function editorInput(): ContextInput | null {
const same = editor && editor.ctx.path === path;
const state = same ? editor.view.state : null;
return {
...projectContext(),
path,
root: useProject.getState().project?.root ?? null,
configs: useRun.getState().configs,
content: state ? state.doc.sliceString(0, CONTENT_LIMIT) : undefined,
line: state ? state.doc.lineAt(state.selection.main.head).number - 1 : undefined,
goModule: goModulePath,
};
}
@@ -148,7 +173,7 @@ export async function compiledDebugPlan(config: RunConfig): Promise<Produced | n
} catch {
return null;
}
const input: ContextInput = { path, root, configs: useRun.getState().configs, content };
const input: ContextInput = { ...projectContext(), root, path, content };
const env = await toolEnv(neededTools(input), path, root, false);
if (m[2]) {
// A test: find the target by name again.
@@ -262,13 +287,11 @@ function contextArg(arg: unknown): (ContextInput & { dir?: boolean }) | null {
const editor = activeEditor();
const state = editor && editor.ctx.path === a.path ? editor.view.state : null;
return {
...projectContext(),
path: a.path,
dir: a.dir,
root: useProject.getState().project?.root ?? null,
configs: useRun.getState().configs,
content: a.content ?? state?.doc.sliceString(0, CONTENT_LIMIT) ?? peekContent(a.path),
line: a.line,
goModule: goModulePath,
};
}
@@ -327,12 +350,11 @@ export function contextMenuItems(menu: ContextMenuId, ctx: MenuGroupContext): Me
const content = ctx.text ?? peekContent(path);
const line = menu === "editor" ? ctx.line : undefined;
const input: ContextInput = {
path,
...projectContext(),
root,
configs: useRun.getState().configs,
path,
content: content?.slice(0, CONTENT_LIMIT),
line,
goModule: goModulePath,
};
const p = dir ? produceDirectory(input) : produceContext(input, OPTIMISTIC);
if (!p) return [];
+31
Переглянути файл
@@ -0,0 +1,31 @@
// OpenIDEA: runs a main class on its Gradle source set's runtime classpath, like IntelliJ
// IDEA's "Build and run using: Gradle" (ApplicationTaskInitScript.gradle). Parameters come
// as system properties so this file never changes: see `gradleRun.ts`.
import groovy.json.JsonSlurper
def target = System.getProperty('openidea.run.project')
if (target != null) {
allprojects { p ->
// `target` is an identity path (`:lib:core` for a project of an included build).
def id = { try { p.identityPath.toString() } catch (ignored) { p.path } }
if (id() != target) return
p.plugins.withId('java') {
def json = new JsonSlurper()
def list = { String key -> (List) json.parseText(System.getProperty(key, '[]')) }
p.tasks.register('openideaRun', JavaExec) { t ->
t.description = 'Runs a main class for OpenIDEA.'
t.classpath = p.sourceSets.getByName(System.getProperty('openidea.run.sourceSet', 'main')).runtimeClasspath
t.mainClass.set(System.getProperty('openidea.run.main'))
t.args(list('openidea.run.args'))
t.jvmArgs(list('openidea.run.jvmArgs'))
// The IDE attaches to this JVM, not to the Gradle daemon; port 0 prints the one picked.
if (Boolean.getBoolean('openidea.run.debug')) {
t.jvmArgs('-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0')
}
def dir = System.getProperty('openidea.run.workingDir')
if (dir) t.workingDir = dir
t.standardInput = System.in
}
}
}
}
+124
Переглянути файл
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import type { GradleModel, RunConfig } from "@/platform/ipc";
import { applicationConfig } from "./application";
import { DELEGATED_DEBUG, delegatedGradleConfig, wrapperGradleVersion } from "./gradleRun";
import { jdwpLaunch } from "./javaDebug";
import { isJvmApplication } from "@/platform/javaLaunch";
const ROOT = "/w/shop";
const CORE = "/w/billing/modules/core";
const gradle = {
dir: "",
command: "/w/shop/gradlew",
env: { JAVA_HOME: "/jdk21" },
projects: [
{ path: ":", name: "Shop", dir: "", buildFile: null, tasks: [] },
{ path: ":billing:core", name: "core", dir: CORE, buildFile: null, tasks: [] },
{ path: ":app", name: "app", dir: "app", buildFile: null, tasks: [] },
],
} as unknown as GradleModel;
const ctx = {
root: ROOT,
gradle,
script: "/tmp/openidea-gradle-run.gradle",
offline: false,
configurationCache: false,
};
describe("Build and run using: Gradle", () => {
it("runs an Application of an includeFlat module as its JavaExec task", () => {
const app: RunConfig = {
...applicationConfig(
{ className: "com.example.ShopApplication", spring: true },
`${CORE}/src/main/java/com/example/ShopApplication.java`,
[],
),
args: ["-Xmx1g", "-cp", "${classpath}", "com.example.ShopApplication", "--port", "8081 x"],
env: { SPRING_PROFILES_ACTIVE: "dev" },
};
const run = delegatedGradleConfig(app, CORE, ctx)!;
expect(run).toMatchObject({
kind: "gradle",
name: "ShopApplication",
command: "/w/shop/gradlew",
cwd: null,
module: CORE,
env: { JAVA_HOME: "/jdk21", SPRING_PROFILES_ACTIVE: "dev" },
});
expect(run.args).toEqual([
"--console=plain",
"--init-script",
"/tmp/openidea-gradle-run.gradle",
"-Dopenidea.run.project=:billing:core",
"-Dopenidea.run.main=com.example.ShopApplication",
"-Dopenidea.run.sourceSet=main",
'-Dopenidea.run.args=["--port","8081 x"]',
'-Dopenidea.run.jvmArgs=["-Xmx1g"]',
`-Dopenidea.run.workingDir=${CORE}`,
":billing:core:openideaRun",
]);
});
it("uses the test classpath, a project-relative module and offline mode", () => {
const test = applicationConfig({ className: "a.Tool" }, "app/src/test/java/a/Tool.java", []);
const run = delegatedGradleConfig(test, "app", { ...ctx, offline: true })!;
expect(run.args).toContain("-Dopenidea.run.sourceSet=test");
expect(run.args).toContain("-Dopenidea.run.workingDir=/w/shop/app");
expect(run.args.slice(-2)).toEqual(["--offline", ":app:openideaRun"]);
});
it("reuses Gradle's configuration cache on Gradle 8.1+ wrappers", () => {
const app = applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java", []);
const run = delegatedGradleConfig(app, "app", { ...ctx, configurationCache: true })!;
expect(run.args.slice(-3)).toEqual([
"--configuration-cache",
"--configuration-cache-problems=warn",
":app:openideaRun",
]);
const props = (url: string) => `distributionBase=GRADLE_USER_HOME\ndistributionUrl=${url}\n`;
expect(wrapperGradleVersion(props("https\\://services.gradle.org/distributions/gradle-8.10.2-bin.zip"))).toEqual([
8, 10,
]);
expect(wrapperGradleVersion(props("https://x/gradle-7.6-all.zip"))).toEqual([7, 6]);
expect(wrapperGradleVersion("# no distribution\n")).toBeNull();
});
it("leaves mains outside the build's modules to the language server", () => {
const stray = applicationConfig({ className: "x.Main" }, "/elsewhere/src/main/java/x/Main.java", []);
expect(delegatedGradleConfig(stray, "/elsewhere", ctx)).toBeNull();
});
it("runs included builds' modules by identity path, never buildSrc's", () => {
const nested = {
...ctx,
gradle: {
...gradle,
projects: [
...gradle.projects,
{ path: ":lib:core", name: "core", dir: "/w/lib/core", buildFile: null, tasks: [], build: ":lib" },
{ path: ":buildSrc", name: "buildSrc", dir: "buildSrc", buildFile: null, tasks: [], build: ":buildSrc" },
],
},
};
const main = (file: string) => applicationConfig({ className: "a.Main" }, file, []);
const lib = delegatedGradleConfig(main("/w/lib/core/src/main/java/a/Main.java"), "/w/lib/core", nested)!;
expect(lib.args.at(-1)).toBe(":lib:core:openideaRun");
expect(delegatedGradleConfig(main("buildSrc/src/main/java/a/Main.java"), "buildSrc", nested)).toBeNull();
});
it("is a Gradle run from then on: never delegated twice, the Application's params dropped", () => {
const app = { ...applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java", []) };
const run = delegatedGradleConfig({ ...app, params: { vmOptions: "-Xmx1g" } }, "app", ctx)!;
expect(isJvmApplication(run)).toBe(false);
expect(run.params).toBeUndefined();
});
it("debugs the forked JVM: the init script adds the agent", () => {
const app = applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java", []);
const run = delegatedGradleConfig(app, "app", ctx)!;
const debug = jdwpLaunch(run)!;
expect(debug.args.at(-1)).toBe(DELEGATED_DEBUG);
expect(debug.args).not.toContain("--debug-jvm");
expect(jdwpLaunch(debug)!.args.filter((a) => a === DELEGATED_DEBUG)).toHaveLength(1);
});
});
+134
Переглянути файл
@@ -0,0 +1,134 @@
/**
* IDEA's "Build and run using: Gradle": an Application / Spring Boot configuration of a
* Gradle module keeps its settings, only its launch becomes a Gradle run of a `JavaExec`
* task (`gradleRun.gradle`) on the source set's runtime classpath. Gradle compiles the
* module and its dependencies and processes resources first; the debugger attaches to
* the forked JVM (`-Dopenidea.run.debug`), not to the Gradle daemon.
*/
import RUN_SCRIPT from "./gradleRun.gradle?raw";
import { useBuildModel } from "@/platform/buildModel";
import { call, commands, isTauri, type GradleModel, type RunConfig } from "@/platform/ipc";
import { findJavaModule, isJvmApplication, parseJavaArgs } from "@/platform/javaLaunch";
import { useProject } from "@/platform/project";
/** System property that marks a delegated run (read by `jdwpLaunch` to add the debug flag). */
export const DELEGATED_MAIN = "-Dopenidea.run.main=";
export const DELEGATED_DEBUG = "-Dopenidea.run.debug=true";
const TASK = "openideaRun";
const isAbsolute = (p: string) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
const absolute = (root: string, p: string) => (isAbsolute(p) ? p : p ? `${root}/${p}` : root);
export interface DelegationContext {
/** Project root. */
root: string;
gradle: GradleModel;
/** Absolute path of the written `gradleRun.gradle`. */
script: string;
offline: boolean;
/**
* Gradle's configuration cache: a repeated launch skips configuring the build (about half of
* Gradle's share of a warm start). Only for Gradle 8.1+, where it is stable.
*/
configurationCache: boolean;
}
/** Major / minor of the wrapper's `distributionUrl` (`gradle-8.10.2-bin.zip`), null when unknown. */
export function wrapperGradleVersion(properties: string): [number, number] | null {
const m = /^\s*distributionUrl\s*=.*gradle-(\d+)\.(\d+)/m.exec(properties);
return m ? [Number(m[1]), Number(m[2])] : null;
}
const atLeast = (v: [number, number] | null, major: number, minor: number) =>
!!v && (v[0] > major || (v[0] === major && v[1] >= minor));
/**
* The Gradle run of `config`, or null when it is not an Application of a module of this
* Gradle build (`module`: its folder, project-relative or absolute).
*/
export function delegatedGradleConfig(config: RunConfig, module: string, ctx: DelegationContext): RunConfig | null {
const parsed = parseJavaArgs(config.args);
if (!parsed.mainClass) return null;
const dir = absolute(ctx.root, module);
const project = ctx.gradle.projects.find((p) => absolute(ctx.root, p.dir) === dir);
// buildSrc tasks are not addressable from the root build; included builds' are (`:lib:core:…`).
if (!project || project.build?.endsWith(":buildSrc")) return null;
const task = project.path === ":" ? `:${TASK}` : `${project.path}:${TASK}`;
const args = [
"--console=plain",
"--init-script",
ctx.script,
`-Dopenidea.run.project=${project.path}`,
`${DELEGATED_MAIN}${parsed.mainClass}`,
`-Dopenidea.run.sourceSet=${parsed.classpath === "test" ? "test" : "main"}`,
`-Dopenidea.run.args=${JSON.stringify(parsed.programArgs)}`,
`-Dopenidea.run.jvmArgs=${JSON.stringify(parsed.vmArgs)}`,
`-Dopenidea.run.workingDir=${config.cwd ? absolute(ctx.root, config.cwd) : dir}`,
...(ctx.offline ? ["--offline"] : []),
// Problems only warn: a build with incompatible plugins still runs, uncached.
...(ctx.configurationCache ? ["--configuration-cache", "--configuration-cache-problems=warn"] : []),
task,
];
// A Gradle run from here on: not an Application again (it would be delegated twice), and
// without the Application's params (Gradle's own VM-options init script would repeat them).
const { params: _params, ...rest } = config;
return {
...rest,
kind: "gradle",
command: ctx.gradle.command,
args,
cwd: ctx.gradle.dir || null,
// The Gradle JVM's JAVA_HOME first; the configuration's own environment wins.
env: { ...ctx.gradle.env, ...config.env },
module: module,
};
}
/** Writes the init script where Gradle can read it; it never changes, so once per session. */
let scriptPath: Promise<string> | null = null;
function writeScript(): Promise<string> {
scriptPath ??= (async () => {
const { tempDir, join } = await import("@tauri-apps/api/path");
const path = await join(await tempDir(), "openidea-gradle-run.gradle");
await call(commands.fsDelete(path)).catch(() => {});
await call(commands.fsCreateFileBytes(path, Array.from(new TextEncoder().encode(RUN_SCRIPT))));
return path;
})().catch((e) => {
scriptPath = null;
throw e;
});
return scriptPath;
}
/**
* The launch of an Application / Spring Boot `config` through Gradle, when the project
* runs with Gradle (Settings › Build Tools › Gradle) and the main class is in one of its
* modules; null to launch it on the language server's classpath.
*/
export async function gradleDelegation(config: RunConfig): Promise<RunConfig | null> {
if (!isTauri() || !isJvmApplication(config)) return null;
const { project, settings } = useProject.getState();
if (!project?.root || settings?.languages?.java?.gradle?.runUsing === "ide") return null;
const { model, gradleOffline } = useBuildModel.getState();
if (!model?.gradle) return null;
const module = config.module ?? (await findJavaModule(config));
if (module === null) return null;
const ctx = {
root: project.root,
gradle: model.gradle,
script: "",
offline: gradleOffline,
configurationCache: false,
};
if (!delegatedGradleConfig(config, module, ctx)) return null;
const [script, configurationCache] = await Promise.all([writeScript(), supportsConfigurationCache(ctx)]);
return delegatedGradleConfig(config, module, { ...ctx, script, configurationCache });
}
/** The wrapper's Gradle is 8.1+ (a local or PATH Gradle's version is not known here). */
async function supportsConfigurationCache(ctx: Pick<DelegationContext, "root" | "gradle">): Promise<boolean> {
if (!/(^|[/\\])gradlew(\.bat)?$/.test(ctx.gradle.command)) return false;
const props = `${absolute(ctx.root, ctx.gradle.dir)}/gradle/wrapper/gradle-wrapper.properties`;
const text = await call(commands.fsReadText(props)).catch(() => "");
return atLeast(wrapperGradleVersion(text), 8, 1);
}
+7
Переглянути файл
@@ -47,6 +47,7 @@ import { isDebugOnly, registerBuiltinTypes } from "./types";
import { listenForApplicationDeactivation, showApplicationUpdate, updatableSessions } from "./applicationUpdate";
import { npmGutter } from "./npmGutter";
import { runGutter } from "./runGutter";
import { gradleDelegation } from "./gradleRun";
import { activateStackTrace, useStackTraces } from "./stackTrace";
import { clearRuntimeCache } from "./runtimes";
import {
@@ -119,6 +120,12 @@ async function debugConfig(config: RunConfig, chain: readonly string[] = []) {
await launchProduced(compiled, true);
return;
}
// "Build and run using: Gradle": the JavaExec JVM gets the agent, the debugger attaches to it.
const delegated = getAction("debug.attach") ? await gradleDelegation(config) : null;
if (delegated) {
await debugWithAgent(delegated);
return;
}
// Maven / Gradle: the build tool starts the JVM with a debug agent, the debugger attaches.
if (jdwpLaunch(config) && getAction("debug.attach")) {
await debugWithAgent(config);
+11
Переглянути файл
@@ -140,6 +140,17 @@ describe("application configurations", () => {
expect(jvmModuleDir("odd/App.java", [cfg({ id: "m", kind: "maven", cwd: "odd" })])).toBe("odd");
});
it("takes the innermost module the build model knows, like IDEA's file index", () => {
const modules = ["", "/w/billing", "/w/billing/modules/core", "legacy"];
// `sourceSets.main.java.srcDirs = ['src']`: no `src/main/java` to guess from.
expect(jvmModuleDir("legacy/src/a/App.java", [], modules)).toBe("legacy");
expect(jvmModuleDir("/w/billing/modules/core/src/main/java/a/App.java", [], modules)).toBe(
"/w/billing/modules/core",
);
// A module the model does not know yet (before the first sync): the source layout.
expect(jvmModuleDir("services/api/src/main/java/a/App.java", [], modules)).toBe("services/api");
});
it("adds JDWP to a script explicitly assigned the Kotlin debugger", () => {
const rc = cfg({ id: "kotlin-script", kind: "shell", command: "./run.sh", debugAdapter: "kotlin-debug-adapter" });
expect(jdwpLaunch(rc)?.env.JAVA_TOOL_OPTIONS).toContain("-agentlib:jdwp=");
+6 -1
Переглянути файл
@@ -12,6 +12,7 @@ import { executeAction } from "@/platform/actions";
import type { RunConfig } from "@/platform/ipc";
import { isJvmApplication, JDWP_AGENT, jdwpListeningPort } from "@/platform/javaLaunch";
import { startRun, useRun } from "./store";
import { DELEGATED_DEBUG, DELEGATED_MAIN } from "./gradleRun";
const isMavenTestGoal = (a: string) =>
/^(test|verify|integration-test|surefire:test|failsafe:integration-test)$/.test(a);
@@ -55,6 +56,9 @@ export function jdwpLaunch(config: RunConfig): RunConfig | null {
return null;
}
if (config.kind === "gradle" || /(^|\/)gradlew?(\.bat)?$/.test(config.command)) {
// A delegated Application: its init script adds the agent (port 0) to the JavaExec JVM.
if (args.some((a) => a.startsWith(DELEGATED_MAIN)))
return args.includes(DELEGATED_DEBUG) ? config : { ...config, args: [...args, DELEGATED_DEBUG] };
if (!args.some(isGradleJvmTask)) return null;
return args.includes("--debug-jvm") ? config : { ...config, args: [...args, "--debug-jvm"] };
}
@@ -74,7 +78,8 @@ export async function debugWithAgent(config: RunConfig): Promise<boolean> {
const runId = useRun.getState().sessions.find((s) => s.id === sessionId)?.runId ?? null;
void executeAction("debug.attach", {
name: config.name,
module: config.cwd ?? "",
// The application's module (a delegated run's working directory is the Gradle build).
module: config.module ?? config.cwd ?? "",
hostName: "127.0.0.1",
port,
process: runId,
+22
Переглянути файл
@@ -309,6 +309,28 @@ describe("single-file producers (scratches)", () => {
expect(p.config!.args.at(-1)).toBe("demo.Hi");
});
it("Java: a main in a Gradle module outside the project runs on the module's classpath", () => {
// `includeFlat 'billing'`: the module folder is a sibling of the project folder.
const svc = "/work/billing/modules/core";
const boot =
"package com.example;\nimport org.springframework.boot.SpringApplication;\n@SpringBootApplication\n" +
"public class ShopApplication {\n public static void main(String[] args) {\n" +
" SpringApplication.run(ShopApplication.class, args);\n }\n}";
const gradle = [cfg({ id: "gradle:test", kind: "gradle", command: "./gradlew", args: ["test"], isTest: true })];
const input = (contentRoots?: string[]) => ({
path: `${svc}/src/main/java/com/example/ShopApplication.java`,
root: ROOT,
configs: gradle,
content: boot,
line: 4,
contentRoots,
});
expect(produceContext(input(), ALL)!.config!.kind).toBe("java-file");
const p = produceContext(input(["/work/billing", svc]), ALL)!;
expect(p.config).toMatchObject({ kind: "spring-boot", module: svc, cwd: svc });
expect(p.config!.args).toEqual(["-cp", "${classpath}", "com.example.ShopApplication"]);
});
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 =
+21 -7
Переглянути файл
@@ -56,6 +56,12 @@ export interface ContextInput {
goModule?: (dir: string) => string | null | undefined;
/** 0-based caret line: the element at the caret wins over the file. */
line?: number;
/**
* Absolute folders outside `root` that belong to the project (IDEA's module content
* roots: `includeFlat` Gradle modules, Project Structure roots); their files run like
* the project's own, through its build.
*/
contentRoots?: readonly string[];
}
export interface Missing {
@@ -363,8 +369,14 @@ interface FileSite {
inProject: boolean;
}
/** In the project folder or one of its content roots: run through the project's build. */
function isProjectPath(input: Pick<ContextInput, "path" | "root" | "contentRoots">): boolean {
if (!input.root) return false;
return [input.root, ...(input.contentRoots ?? [])].some((r) => inProject(input.path, r));
}
function site(input: ContextInput): FileSite {
const inside = inProject(input.path, input.root);
const inside = isProjectPath(input);
const rel = relTo(input.path, input.root);
return {
idPath: rel,
@@ -809,8 +821,11 @@ export function standaloneTargets(content: string, path: string): RunTarget[] {
}
/** Run targets of a file for the caret / gutter: project-aware inside the project, standalone outside. */
export function fileTargets(input: Pick<ContextInput, "path" | "root" | "configs">, content: string): RunTarget[] {
const s = { inProject: inProject(input.path, input.root), rel: relTo(input.path, input.root) };
export function fileTargets(
input: Pick<ContextInput, "path" | "root" | "configs" | "contentRoots">,
content: string,
): RunTarget[] {
const s = { inProject: isProjectPath(input), rel: relTo(input.path, input.root) };
const rust = /\.rs$/.test(input.path);
if (/\.(c|cpp|cc|cxx)$/.test(input.path)) return standaloneTargets(content, input.path);
const cargo =
@@ -879,8 +894,7 @@ export function produceContext(input: ContextInput, env: ToolEnv): Produced | nu
*/
export function produceContextChoices(input: ContextInput, env: ToolEnv): Produced[] {
const produced = produceContext(input, env);
if (!produced?.config || produced.scope === "directory" || !inProject(input.path, input.root))
return produced ? [produced] : [];
if (!produced?.config || produced.scope === "directory" || !isProjectPath(input)) return produced ? [produced] : [];
const rel = relTo(input.path, input.root);
const cmake = cmakeTargets(input);
if (cmake.length > 1) return cmake.map((config) => ({ name: config.name, scope: produced.scope, config }));
@@ -907,7 +921,7 @@ export function produceContextChoices(input: ContextInput, env: ToolEnv): Produc
}
function cmakeTargets(input: ContextInput): RunConfig[] {
if (!inProject(input.path, input.root)) return [];
if (!isProjectPath(input)) return [];
const lang = scriptLanguage(input.path, input.content);
if (lang !== "c" && lang !== "cpp") return [];
const rel = relTo(input.path, input.root);
@@ -921,7 +935,7 @@ const TEST_DIR = /(^|\/)(tests?|__tests__|specs?|test_?\w*)$/;
export function produceDirectory(input: ContextInput): Produced | null {
const { root } = input;
if (!root || !inProject(input.path, root) || input.path === root) return null;
if (!root || !isProjectPath(input) || input.path === root) return null;
const rel = relTo(input.path, root);
const name = baseName(rel);
const jvmRoot = /^(?:(.*)\/)?src\/test(?:\/(java|kotlin|scala|groovy))?$/.exec(rel);
+11 -12
Переглянути файл
@@ -12,8 +12,15 @@ import { isVirtualUri } from "@/platform/virtualDocs";
import { shortcutLabel } from "@/platform/keymap";
import { gutterIconMarkup, showContextMenu, type MenuItem } from "@/ui";
import { notifications, runExecutors } from "@/workbench/contributions";
import { choiceItems, executeContext, launchProduced, modifyContext, produceResolved } from "./contextRun";
import { goModulePath, loadGoModules } from "./goModules";
import {
choiceItems,
executeContext,
launchProduced,
modifyContext,
produceResolved,
projectContext,
} from "./contextRun";
import { loadGoModules } from "./goModules";
import { fileTargets, OPTIMISTIC, produceContextChoices, type ContextInput } from "./producers";
import { loadConfigs, useRun } from "./store";
import { targetLanguage, type RunTarget } from "./targets";
@@ -52,14 +59,7 @@ const targetsField = StateField.define<PlacedTarget[]>({
});
function targetInput(path: string, content: string, line: number): ContextInput {
return {
path,
root: useProject.getState().project?.root ?? null,
configs: useRun.getState().configs,
content,
line,
goModule: goModulePath,
};
return { ...projectContext(), path, content, line };
}
async function launch(target: RunTarget, path: string, debug: boolean, content: string) {
@@ -197,8 +197,7 @@ export function runGutter(ctx: EditorDocContext): Extension {
if (!useProject.getState().project?.root || isVirtualUri(path) || !hasGutterTargets(path)) return [];
if (/\.go$/.test(path))
void loadGoModules(useProject.getState().project?.root ?? null, useRun.getState().configs).catch(() => {});
const detect = (text: string) =>
fileTargets({ path, root: useProject.getState().project?.root ?? null, configs: useRun.getState().configs }, text);
const detect = (text: string) => fileTargets({ ...projectContext(), path }, text);
const detector = ViewPlugin.fromClass(
class {
+6 -1
Переглянути файл
@@ -53,6 +53,7 @@ import { typeFor } from "./types";
import { recordRun } from "./services";
import { startedPort } from "./serverPort";
import { tomcatBrowserUrl } from "./tomcat";
import { gradleDelegation } from "./gradleRun";
import { openUrl } from "./navigate";
import { tx } from "@/platform/i18n";
@@ -661,7 +662,11 @@ export async function startRun(
};
try {
let launch = options.launch ?? config;
if (isJvmApplication(launch)) {
// "Build and run using: Gradle": the module's Gradle build compiles and launches it.
const delegated = isJvmApplication(launch) ? await gradleDelegation(launch) : null;
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
if (delegated) launch = delegated;
else if (isJvmApplication(launch)) {
const resolved = await resolveJvmApplication(sessionId, launch);
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
if (!resolved) {
+15
Переглянути файл
@@ -318,9 +318,24 @@ export function JavaPage() {
export function GradlePage() {
const [distribution, setDistribution] = useSetting<string>("languages.java.gradle.distribution");
const [runUsing, setRunUsing] = useSetting<string>("languages.java.gradle.runUsing");
return (
<div className={styles.page}>
<Section>
<Row
label="Build and run using"
hint="Gradle runs a main class as a JavaExec task on its source set's runtime classpath (Gradle's resource processing included). OpenIDEA launches it on the Java language server's classpath."
>
<Select
value={runUsing || "gradle"}
onChange={setRunUsing}
aria-label="Build and run using"
options={[
{ value: "gradle", label: "Gradle" },
{ value: "ide", label: "OpenIDEA" },
]}
/>
</Row>
<SdkRow
k="languages.java.gradle.jvm"
label="Gradle JVM"
+3
Переглянути файл
@@ -118,6 +118,9 @@ export const EXECUTION_PAGES: PageSpec[] = [
group: BUILD_GROUP,
order: 358,
keywords: [
"Build and run using",
"delegate",
"JavaExec",
"Gradle JVM",
"JDK",
"JAVA_HOME",