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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-04 06:14:07 +08:00
1 джерело afea58c1c6
коміт 63dd83c129
3 files changed
+326 -1

No files matched your search

+3 -1
Переглянути файл
@@ -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,
+74
Переглянути файл
@@ -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" },
]);
});
});
+249
Переглянути файл
@@ -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 [];
}