From 63dd83c12992096bfa62f007972ffb6e33b4f1c6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:14:07 +0800 Subject: [PATCH] feat(build-tools): navigate Gradle version catalog accessors to their TOML entries (#1109) In build scripts, Go to Declaration on a catalog accessor (libs., 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. --- src/features/build-tools/index.ts | 4 +- .../build-tools/versionCatalog.test.ts | 74 ++++++ src/features/build-tools/versionCatalog.ts | 249 ++++++++++++++++++ 3 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 src/features/build-tools/versionCatalog.test.ts create mode 100644 src/features/build-tools/versionCatalog.ts diff --git a/src/features/build-tools/index.ts b/src/features/build-tools/index.ts index 58d31bc7a..aab16b65e 100644 --- a/src/features/build-tools/index.ts +++ b/src/features/build-tools/index.ts @@ -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, diff --git a/src/features/build-tools/versionCatalog.test.ts b/src/features/build-tools/versionCatalog.test.ts new file mode 100644 index 000000000..bc2c695a5 --- /dev/null +++ b/src/features/build-tools/versionCatalog.test.ts @@ -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" }, + ]); + }); +}); diff --git a/src/features/build-tools/versionCatalog.ts b/src/features/build-tools/versionCatalog.ts new file mode 100644 index 000000000..64330a120 --- /dev/null +++ b/src/features/build-tools/versionCatalog.ts @@ -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 = 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 { + 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(); +const pending = new Map>(); +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 []; +}