дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(build-tools): navigate Gradle version catalog accessors to their TOML entries (#1109)
In build scripts, Go to Declaration on a catalog accessor (libs.<alias>, versions./bundles./plugins.) opens the entry in the catalog file and hover shows its declaration with the referenced version. Catalogs are the build's gradle/libs.versions.toml and those declared in settings with from(files(...)), re-read when they change. In a catalog, version.ref values and bundle members go to their entries.
Цей коміт міститься в:
1 джерело
afea58c1c6
коміт
63dd83c129
3 files changed
+326
-1
No files matched your search
@@ -3,7 +3,7 @@
|
||||
* project has that build system, like IDEA. The model is re-read when build
|
||||
* files change; running goals / tasks goes through the run feature's actions.
|
||||
*/
|
||||
import { onEditorEvent } from "@/editor/api";
|
||||
import { onEditorEvent, registerEditorContribution } from "@/editor/api";
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { onIdeEvent, type BuildModel, type BuildTool } from "@/platform/ipc";
|
||||
@@ -19,6 +19,7 @@ import { lazyComponent } from "@/ui";
|
||||
import { GradleView, MavenView } from "./BuildToolView";
|
||||
import { ExecutePopupHost } from "./ExecutePopup";
|
||||
import { buildToolsRunAnything } from "./runAnything";
|
||||
import { versionCatalogExtension } from "./versionCatalog";
|
||||
import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots } from "./model";
|
||||
import {
|
||||
ANALYZER_KIND,
|
||||
@@ -133,6 +134,7 @@ export function activate(): () => void {
|
||||
};
|
||||
const disposers: Array<() => void> = [
|
||||
registerOverlay({ id: "build-tools.execute", component: ExecutePopupHost }),
|
||||
registerEditorContribution({ id: "build-tools.versionCatalog", extensions: versionCatalogExtension }),
|
||||
registerRunAnythingProvider(buildToolsRunAnything),
|
||||
registerEditorKind({
|
||||
kind: ANALYZER_KIND,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { accessorSegments, catalogEntry, chainAt, declaredCatalogs, parseCatalog } from "./versionCatalog";
|
||||
|
||||
const toml = `[versions]
|
||||
springBoot = "3.5.16"
|
||||
kotlin = "2.2.0" # comment
|
||||
|
||||
[libraries]
|
||||
spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "springBoot" }
|
||||
"jackson_kotlin" = "com.fasterxml.jackson.module:jackson-module-kotlin:2.19.0"
|
||||
groovy.module = "org.apache.groovy:groovy"
|
||||
|
||||
[bundles]
|
||||
web = [
|
||||
"spring-boot-starter",
|
||||
"jackson_kotlin",
|
||||
]
|
||||
|
||||
[plugins]
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
`;
|
||||
|
||||
describe("Gradle version catalogs", () => {
|
||||
const catalog = parseCatalog("libs", "/p/gradle/libs.versions.toml", toml);
|
||||
|
||||
it("reads the aliases of each table with where they are declared", () => {
|
||||
expect(catalog.entries.map((e) => [e.section, e.alias, e.line, e.col])).toEqual([
|
||||
["versions", "springBoot", 1, 0],
|
||||
["versions", "kotlin", 2, 0],
|
||||
["libraries", "spring-boot-starter", 5, 0],
|
||||
["libraries", "jackson_kotlin", 6, 1],
|
||||
["libraries", "groovy", 7, 0],
|
||||
["bundles", "web", 10, 0],
|
||||
["plugins", "kotlin-jvm", 16, 0],
|
||||
]);
|
||||
expect(catalog.entries[1]!.value).toBe('"2.2.0"');
|
||||
});
|
||||
|
||||
it("maps build script accessors to entries the way Gradle generates them", () => {
|
||||
expect(accessorSegments("spring-boot_starter.web")).toEqual(["spring", "boot", "starter", "web"]);
|
||||
const alias = (segments: string[]) => catalogEntry(catalog, segments)?.alias;
|
||||
expect(alias(["spring", "boot", "starter"])).toBe("spring-boot-starter");
|
||||
expect(alias(["jackson", "kotlin"])).toBe("jackson_kotlin");
|
||||
expect(alias(["versions", "springBoot", "get"])).toBe("springBoot");
|
||||
expect(alias(["bundles", "web"])).toBe("web");
|
||||
expect(alias(["plugins", "kotlin", "jvm"])).toBe("kotlin-jvm");
|
||||
expect(alias(["spring", "web"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("finds the accessor chain under the caret", () => {
|
||||
const line = " implementation(libs.spring.boot.starter)";
|
||||
expect(chainAt(line, line.indexOf("boot"))).toEqual({
|
||||
from: line.indexOf("libs"),
|
||||
to: line.indexOf(")"),
|
||||
segments: ["libs", "spring", "boot", "starter"],
|
||||
});
|
||||
expect(chainAt(line, 6)).toBeNull();
|
||||
});
|
||||
|
||||
it("reads catalogs declared in settings", () => {
|
||||
const settings = `dependencyResolutionManagement {
|
||||
versionCatalogs {
|
||||
create("commonLibs") { from(files("../shared/common.versions.toml")) }
|
||||
create('tools') {
|
||||
from(files('gradle/tools.versions.toml'))
|
||||
}
|
||||
}
|
||||
}`;
|
||||
expect(declaredCatalogs(settings)).toEqual([
|
||||
{ name: "commonLibs", file: "../shared/common.versions.toml" },
|
||||
{ name: "tools", file: "gradle/tools.versions.toml" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Gradle version catalogs (`gradle/libs.versions.toml`, catalogs declared in settings): in a build
|
||||
* script, Go to Declaration on `libs.spring.boot.starter` opens that alias in the TOML file and hover
|
||||
* shows its declaration; in a catalog, `version.ref = "x"` and bundle members go to their entries.
|
||||
* Read from the files themselves (no Gradle run), and re-read when a catalog or settings file changes.
|
||||
*/
|
||||
import { EditorState, type Extension } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import {
|
||||
CONTRIBUTED_DEFINITION,
|
||||
CONTRIBUTED_HOVER,
|
||||
type ContributedDefinition,
|
||||
type ContributedHover,
|
||||
type EditorDocContext,
|
||||
} from "@/editor/api";
|
||||
import { call, commands, onIdeEvent } from "@/platform/ipc";
|
||||
import { dirname, useProject } from "@/platform/project";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
|
||||
type Section = "versions" | "libraries" | "bundles" | "plugins";
|
||||
|
||||
export interface CatalogEntry {
|
||||
section: Section;
|
||||
alias: string;
|
||||
/** 0-based line and UTF-16 column of the alias. */
|
||||
line: number;
|
||||
col: number;
|
||||
/** The declaration's value as written (first line). */
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Catalog {
|
||||
/** Accessor name in build scripts (`libs`). */
|
||||
name: string;
|
||||
path: string;
|
||||
entries: CatalogEntry[];
|
||||
}
|
||||
|
||||
const SECTIONS: ReadonlySet<string> = new Set(["versions", "libraries", "bundles", "plugins"]);
|
||||
|
||||
/** Top-level keys of the catalog's four tables, with where they are declared. */
|
||||
export function parseCatalog(name: string, path: string, text: string): Catalog {
|
||||
const entries: CatalogEntry[] = [];
|
||||
let section: Section | null = null;
|
||||
let depth = 0;
|
||||
text.split("\n").forEach((raw, line) => {
|
||||
const header = depth === 0 ? /^\s*\[([^\]]+)\]\s*(#.*)?$/.exec(raw) : null;
|
||||
if (header) {
|
||||
const t = header[1]!.trim();
|
||||
section = SECTIONS.has(t) ? (t as Section) : null;
|
||||
return;
|
||||
}
|
||||
if (section && depth === 0) {
|
||||
const key = /^(\s*)(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))[^=]*?=\s*(.*)$/.exec(raw);
|
||||
if (key) {
|
||||
const alias = key[2] ?? key[3] ?? key[4]!;
|
||||
const col = key[1]!.length + (key[4] === undefined ? 1 : 0);
|
||||
if (!entries.some((e) => e.section === section && e.alias === alias))
|
||||
entries.push({ section, alias, line, col, value: key[5]!.replace(/\s+#.*$/, "").trim() });
|
||||
}
|
||||
}
|
||||
// A bundle's array may span lines.
|
||||
for (const ch of raw.replace(/"[^"]*"|'[^']*'|#.*$/g, "")) {
|
||||
if (ch === "[" || ch === "{") depth++;
|
||||
else if ((ch === "]" || ch === "}") && depth > 0) depth--;
|
||||
}
|
||||
});
|
||||
return { name, path, entries };
|
||||
}
|
||||
|
||||
/** Gradle's accessor for an alias: `-`, `_` and `.` all separate segments. */
|
||||
export function accessorSegments(alias: string): string[] {
|
||||
return alias.split(/[-_.]/).filter(Boolean);
|
||||
}
|
||||
|
||||
/** The entry `segments` (after the catalog name) refer to: the longest prefix that names one. */
|
||||
export function catalogEntry(catalog: Catalog, segments: readonly string[]): CatalogEntry | null {
|
||||
const [first, ...rest] = segments;
|
||||
const section: Section = first === "versions" || first === "bundles" || first === "plugins" ? first : "libraries";
|
||||
const path = section === "libraries" ? segments : rest;
|
||||
for (let n = path.length; n > 0; n--) {
|
||||
const want = path.slice(0, n).join(".");
|
||||
const hit = catalog.entries.find((e) => e.section === section && accessorSegments(e.alias).join(".") === want);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The dotted accessor chain around `pos` in `line`, e.g. `libs.spring.boot.starter`. */
|
||||
export function chainAt(line: string, pos: number): { from: number; to: number; segments: string[] } | null {
|
||||
const word = /[A-Za-z0-9_]/;
|
||||
let from = pos;
|
||||
while (from > 0 && (word.test(line[from - 1]!) || (line[from - 1] === "." && word.test(line[from - 2] ?? ""))))
|
||||
from--;
|
||||
let to = pos;
|
||||
while (to < line.length && (word.test(line[to]!) || (line[to] === "." && word.test(line[to + 1] ?? "")))) to++;
|
||||
if (to <= from || !/^[A-Za-z_]/.test(line[from]!)) return null;
|
||||
const segments = line.slice(from, to).split(".");
|
||||
return segments.length > 1 ? { from, to, segments } : null;
|
||||
}
|
||||
|
||||
/** Catalogs declared in settings: `create("name") { from(files("path")) }`, paths relative to the build root. */
|
||||
export function declaredCatalogs(settings: string): Array<{ name: string; file: string }> {
|
||||
const out: Array<{ name: string; file: string }> = [];
|
||||
const re = /create\(\s*["'](\w+)["']\s*\)\s*\{[^}]*?from\(\s*files\(\s*["']([^"']+)["']/g;
|
||||
for (let m = re.exec(settings); m; m = re.exec(settings)) out.push({ name: m[1]!, file: m[2]! });
|
||||
return out;
|
||||
}
|
||||
|
||||
const readText = (path: string) => call(commands.fsReadText(path)).catch(() => null);
|
||||
const join = (dir: string, rel: string) => (rel.startsWith("/") ? rel : `${dir}/${rel.replace(/^\.\//, "")}`);
|
||||
|
||||
/** The catalogs a build script sees: those of the nearest enclosing build (its settings file). */
|
||||
async function readCatalogs(script: string, root: string): Promise<Catalog[]> {
|
||||
let dir = dirname(script);
|
||||
let settings: string | null = null;
|
||||
for (;;) {
|
||||
settings = (await readText(`${dir}/settings.gradle.kts`)) ?? (await readText(`${dir}/settings.gradle`));
|
||||
if (settings !== null || dir === root || !dir.startsWith(`${root}/`)) break;
|
||||
dir = dirname(dir);
|
||||
}
|
||||
const buildRoot = settings !== null ? dir : root;
|
||||
const declared = declaredCatalogs(settings ?? "");
|
||||
if (!declared.some((d) => d.name === "libs")) declared.push({ name: "libs", file: "gradle/libs.versions.toml" });
|
||||
const catalogs = await Promise.all(
|
||||
declared.map(async ({ name, file }) => {
|
||||
const path = join(buildRoot, file);
|
||||
const text = await readText(path);
|
||||
return text === null ? null : parseCatalog(name, path, text);
|
||||
}),
|
||||
);
|
||||
return catalogs.filter((c): c is Catalog => c !== null);
|
||||
}
|
||||
|
||||
const loaded = new Map<string, Catalog[]>();
|
||||
const pending = new Map<string, Promise<Catalog[]>>();
|
||||
let offFs: (() => void) | null = null;
|
||||
|
||||
function load(script: string): void {
|
||||
const root = useProject.getState().project?.root;
|
||||
if (!root || pending.has(script)) return;
|
||||
offFs ??= onIdeEvent("fsChanged", (e) => {
|
||||
const touched = [...e.created, ...e.modified, ...e.removed].some((p) =>
|
||||
/(\.versions\.toml|\/settings\.gradle(\.kts)?)$/.test(p),
|
||||
);
|
||||
if (!touched) return;
|
||||
const scripts = [...loaded.keys()];
|
||||
pending.clear();
|
||||
scripts.forEach(load);
|
||||
});
|
||||
const p = readCatalogs(script, root);
|
||||
pending.set(script, p);
|
||||
void p.then((c) => {
|
||||
if (pending.get(script) === p) loaded.set(script, c);
|
||||
});
|
||||
}
|
||||
|
||||
function open(entry: CatalogEntry, path: string) {
|
||||
void editorService().openFile(path, {
|
||||
selection: { line: entry.line, col: entry.col, endLine: entry.line, endCol: entry.col + entry.alias.length },
|
||||
});
|
||||
}
|
||||
|
||||
function hoverText(catalog: Catalog, entry: CatalogEntry): string {
|
||||
const lines = [`\`\`\`toml\n${entry.alias} = ${entry.value}\n\`\`\``];
|
||||
const ref = /\bref\s*=\s*["']([^"']+)["']/.exec(entry.value)?.[1];
|
||||
const version = ref && catalog.entries.find((e) => e.section === "versions" && e.alias === ref);
|
||||
if (version) lines.push(`\`${ref}\` = \`${version.value.replace(/^["']|["']$/g, "")}\``);
|
||||
lines.push(catalog.path.slice(catalog.path.lastIndexOf("/") + 1));
|
||||
return lines.join("\n\n");
|
||||
}
|
||||
|
||||
/** Build scripts: catalog accessors navigate to and describe their TOML entries. */
|
||||
function scriptSupport(ctx: EditorDocContext): Extension {
|
||||
load(ctx.path);
|
||||
const at = (state: EditorState, pos: number) => {
|
||||
const line = state.doc.lineAt(pos);
|
||||
const chain = chainAt(line.text, pos - line.from);
|
||||
if (!chain) return null;
|
||||
const catalog = loaded.get(ctx.path)?.find((c) => c.name === chain.segments[0]);
|
||||
const entry = catalog && catalogEntry(catalog, chain.segments.slice(1));
|
||||
return entry ? { catalog, entry, from: line.from + chain.from, to: line.from + chain.to } : null;
|
||||
};
|
||||
const definition: ContributedDefinition = (view, pos) => {
|
||||
const hit = at(view.state, pos);
|
||||
if (!hit) return false;
|
||||
open(hit.entry, hit.catalog.path);
|
||||
return true;
|
||||
};
|
||||
const hover: ContributedHover = (state, pos) => {
|
||||
const hit = at(state, pos);
|
||||
return hit && { from: hit.from, to: hit.to, markdown: hoverText(hit.catalog, hit.entry) };
|
||||
};
|
||||
return EditorState.languageData.of((state, pos) =>
|
||||
at(state, pos) ? [{ [CONTRIBUTED_DEFINITION]: definition, [CONTRIBUTED_HOVER]: hover }] : [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Catalog files: `version.ref = "x"` goes to `[versions] x`, a bundle member to its library. */
|
||||
function catalogSupport(ctx: EditorDocContext): Extension {
|
||||
const at = (state: EditorState, pos: number) => {
|
||||
const line = state.doc.lineAt(pos);
|
||||
const col = pos - line.from;
|
||||
const str = [...line.text.matchAll(/"([^"]*)"|'([^']*)'/g)].find(
|
||||
(m) => m.index! < col && col < m.index! + m[0].length,
|
||||
);
|
||||
if (!str) return null;
|
||||
const catalog = parseCatalog("", ctx.path, state.doc.toString());
|
||||
const name = str[1] ?? str[2]!;
|
||||
const isRef = /\bref\s*=\s*$/.test(line.text.slice(0, str.index));
|
||||
const inBundle = !isRef && sectionAt(state, line.number - 1) === "bundles";
|
||||
if (!isRef && !inBundle) return null;
|
||||
const section: Section = isRef ? "versions" : "libraries";
|
||||
const entry = catalog.entries.find((e) => e.section === section && e.alias === name);
|
||||
return entry ? { catalog, entry, from: line.from + str.index!, to: line.from + str.index! + str[0].length } : null;
|
||||
};
|
||||
const definition: ContributedDefinition = (view: EditorView, pos) => {
|
||||
const hit = at(view.state, pos);
|
||||
if (!hit) return false;
|
||||
const line = view.state.doc.line(hit.entry.line + 1);
|
||||
view.dispatch({
|
||||
selection: { anchor: line.from + hit.entry.col, head: line.from + hit.entry.col + hit.entry.alias.length },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
const hover: ContributedHover = (state, pos) => {
|
||||
const hit = at(state, pos);
|
||||
return hit && { from: hit.from, to: hit.to, markdown: hoverText(hit.catalog, hit.entry) };
|
||||
};
|
||||
return EditorState.languageData.of((state, pos) =>
|
||||
at(state, pos) ? [{ [CONTRIBUTED_DEFINITION]: definition, [CONTRIBUTED_HOVER]: hover }] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function sectionAt(state: EditorState, line: number): string | null {
|
||||
for (let n = line; n >= 0; n--) {
|
||||
const m = /^\s*\[([^\]]+)\]\s*(#.*)?$/.exec(state.doc.line(n + 1).text);
|
||||
if (m) return m[1]!.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function versionCatalogExtension(ctx: EditorDocContext): Extension {
|
||||
if (ctx.virtual) return [];
|
||||
if (/\.gradle(\.kts)?$/.test(ctx.path)) return scriptSupport(ctx);
|
||||
if (ctx.path.endsWith(".versions.toml")) return catalogSupport(ctx);
|
||||
return [];
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача