feat(gradle): navigate to version catalogs imported from published artifacts (#1149)

A catalog declared in settings as create("name") { from("group:artifact:version") } is a published .toml Gradle downloads into its cache. Build-script accessors of such a catalog now go to and describe its entries like a local catalog's: gradle_published_catalog finds the file in Gradle's cache or the local Maven repository on the project's host. A catalog not downloaded yet is looked up again the next time the script opens.

Closes #1006
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-04 10:14:05 +08:00
1 джерело 280c2da48f
коміт 1bc7fd06c1
7 files changed
+165 -18

No files matched your search

+19
Переглянути файл
@@ -222,6 +222,22 @@ fn declaring_pom(
)))
}
/// The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
/// build has downloaded it (`ide_sdk::libraries::published_version_catalog`).
pub async fn gradle_published_catalog(
host: &Host,
window: &str,
coordinates: String,
) -> IdeResult<Option<ide_core::IpcPath>> {
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
ide_sdk::libraries::published_version_catalog(&project.root, &project.settings.effective(), &coordinates)
.map(Into::into)
})
.await
.map_err(join_err)
}
/// Where the direct dependency `dependency` of the Maven module `pom` is declared.
pub async fn maven_dependency_declaration(
host: &Host,
@@ -348,6 +364,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
)
.await
});
d.add("gradle_published_catalog", |c| async move {
gradle_published_catalog(&c.host, &c.window, c.arg("coordinates")?).await
});
d.add("maven_dependency_declaration", |c| async move {
maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await
});
+50
Переглянути файл
@@ -294,6 +294,36 @@ pub(crate) fn dependency_libraries(
.collect()
}
/// The `.toml` of a version catalog published as `group:artifact:version` (Gradle's
/// `version-catalog` plugin) that a build imports with `from("…")`: from Gradle's cache, else
/// the local Maven repository. `None` until a build has downloaded it.
pub fn published_version_catalog(root: &Path, s: &Settings, coordinates: &str) -> Option<PathBuf> {
let mut parts = coordinates.trim().split(':');
let d = BuildDependency {
gradle: true,
group: parts.next()?.to_owned(),
artifact: parts.next()?.to_owned(),
version: parts.next()?.to_owned(),
};
if parts.next().is_some() || !valid_coordinate(&d) {
return None;
}
let file = format!("{}-{}.toml", d.artifact, d.version);
let gradle = crate::apply::setting_path(root, &s.languages.java.gradle.user_home).or_else(gradle_user_home);
let cached = gradle.and_then(|home| {
let dir = home.join("caches/modules-2/files-2.1").join(&d.group).join(&d.artifact).join(&d.version);
let mut found: Vec<PathBuf> =
std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path().join(&file)).filter(|p| p.is_file()).collect();
found.sort();
found.into_iter().next()
});
cached.or_else(|| {
let repo = crate::apply::maven_local_repository(root, s).or_else(default_maven_repository)?;
let path = repo.join(d.group.replace('.', "/")).join(&d.artifact).join(&d.version).join(&file);
path.is_file().then_some(path)
})
}
/// No path separators, interpolations or `..` segments (coordinates become paths).
fn valid_coordinate(d: &BuildDependency) -> bool {
[&d.group, &d.artifact, &d.version]
@@ -530,6 +560,26 @@ pub(crate) fn parse_go_modules(text: &str) -> Vec<(String, PathBuf)> {
#[cfg(test)]
mod go_cache_tests {
use super::*;
#[test]
fn finds_published_version_catalogs_in_the_gradle_cache_and_maven_repository() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut s = Settings::default();
s.languages.java.gradle.user_home = root.join("gradle").to_string_lossy().into_owned();
s.languages.java.maven.local_repository = root.join("m2").to_string_lossy().into_owned();
let cached = root.join("gradle/caches/modules-2/files-2.1/com.acme/catalog/1.2/abc123/catalog-1.2.toml");
std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
std::fs::write(&cached, "[libraries]").unwrap();
assert_eq!(published_version_catalog(root, &s, "com.acme:catalog:1.2"), Some(cached));
let local = root.join("m2/com/acme/other/2.0/other-2.0.toml");
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
std::fs::write(&local, "[libraries]").unwrap();
assert_eq!(published_version_catalog(root, &s, " com.acme:other:2.0 "), Some(local));
for missing in ["com.acme:catalog:9", "com.acme:catalog", "a:b:c:d", "../x:y:z"] {
assert_eq!(published_version_catalog(root, &s, missing), None, "{missing}");
}
}
#[test]
fn vendor_roots_include_local_replacements_once() {
let dir = tempfile::tempdir().unwrap();
+12
Переглянути файл
@@ -43,6 +43,18 @@ pub async fn build_sync(
.await
}
/// The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
/// build has downloaded it.
#[tauri::command]
#[specta::specta]
pub async fn gradle_published_catalog(
window: Window,
state: State<'_, AppState>,
coordinates: String,
) -> IdeResult<Option<ide_core::IpcPath>> {
ide_host::ops::build::gradle_published_catalog(&state.host, window.label(), coordinates).await
}
/// Where the direct dependency `dependency` of the Maven module `pom` is declared.
#[tauri::command]
#[specta::specta]
+1
Переглянути файл
@@ -337,6 +337,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
run::run_test_reports,
build::build_model,
build::build_sync,
build::gradle_published_catalog,
build::maven_dependency_declaration,
build::maven_exclude_dependency,
build::build_run,
+5
Переглянути файл
@@ -638,6 +638,11 @@ export const commands = {
* Returns the refreshed model; a failed build is reported in its `syncError`.
*/
buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], onProgress: Channel<SyncProgress>) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, onProgress })),
/**
* The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
* build has downloaded it.
*/
gradlePublishedCatalog: (coordinates: string) => typedError<string | null, IdeError>(__TAURI_INVOKE("gradle_published_catalog", { coordinates })),
/** Where the direct dependency `dependency` of the Maven module `pom` is declared. */
mavenDependencyDeclaration: (pom: string, dependency: ArtifactKey) => typedError<Location, IdeError>(__TAURI_INVOKE("maven_dependency_declaration", { pom, dependency })),
/**
+45 -2
Переглянути файл
@@ -1,5 +1,24 @@
import { describe, expect, it } from "vitest";
import { accessorSegments, catalogEntry, chainAt, declaredCatalogs, parseCatalog } from "./versionCatalog";
import { describe, expect, it, vi } from "vitest";
const ipc = vi.hoisted(() => ({ fsReadText: vi.fn(), gradlePublishedCatalog: vi.fn() }));
vi.mock("@/platform/ipc", () => ({
call: async (p: Promise<{ status: string; data?: unknown; error?: unknown }>) => {
const r = await p;
if (r.status !== "ok") throw r.error;
return r.data;
},
commands: ipc,
onIdeEvent: () => () => {},
}));
import {
accessorSegments,
catalogEntry,
chainAt,
declaredCatalogs,
parseCatalog,
readCatalogs,
} from "./versionCatalog";
const toml = `[versions]
springBoot = "3.5.16"
@@ -64,11 +83,35 @@ describe("Gradle version catalogs", () => {
create('tools') {
from(files('gradle/tools.versions.toml'))
}
create("platformLibs") { from("com.acme:platform-catalog:1.4.0") }
}
}`;
expect(declaredCatalogs(settings)).toEqual([
{ name: "commonLibs", file: "../shared/common.versions.toml" },
{ name: "tools", file: "gradle/tools.versions.toml" },
{ name: "platformLibs", artifact: "com.acme:platform-catalog:1.4.0" },
]);
});
it("reads a published catalog from where a build downloaded it", async () => {
const files: Record<string, string> = {
"/p/settings.gradle.kts": `dependencyResolutionManagement { versionCatalogs {
create("platformLibs") { from("com.acme:platform-catalog:1.4.0") }
create("later") { from("com.acme:later:1.0") }
} }`,
"/cache/platform-catalog-1.4.0.toml": '[libraries]\nspring-core = "org.springframework:spring-core:6.2.0"\n',
};
ipc.fsReadText.mockImplementation(async (path: string) =>
path in files ? { status: "ok", data: files[path] } : { status: "error", error: { kind: "notFound" } },
);
ipc.gradlePublishedCatalog.mockImplementation(async (coordinates: string) => ({
status: "ok",
data: coordinates === "com.acme:platform-catalog:1.4.0" ? "/cache/platform-catalog-1.4.0.toml" : null,
}));
const { catalogs, complete } = await readCatalogs("/p/build.gradle.kts", "/p");
expect(catalogs.map((c) => [c.name, c.path])).toEqual([["platformLibs", "/cache/platform-catalog-1.4.0.toml"]]);
expect(catalogEntry(catalogs[0]!, ["spring", "core"])?.value).toBe('"org.springframework:spring-core:6.2.0"');
// `later` isn't downloaded yet: read again next time.
expect(complete).toBe(false);
});
});
+33 -16
Переглянути файл
@@ -99,19 +99,29 @@ export function chainAt(line: string, pos: number): { from: number; to: number;
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]! });
export type DeclaredCatalog = { name: string; file: string } | { name: string; artifact: string };
/**
* Catalogs declared in settings: `create("name") { from(files("path")) }`, paths relative to the
* build root, or `create("name") { from("group:artifact:version") }`, a published catalog.
*/
export function declaredCatalogs(settings: string): DeclaredCatalog[] {
const out: DeclaredCatalog[] = [];
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(m[2] !== undefined ? { name: m[1]!, file: m[2] } : { name: m[1]!, artifact: m[3]! });
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[]> {
/**
* The catalogs a build script sees: those of the nearest enclosing build (its settings file).
* `complete` is false while a published catalog isn't downloaded yet.
*/
export async function readCatalogs(script: string, root: string): Promise<{ catalogs: Catalog[]; complete: boolean }> {
let dir = dirname(script);
let settings: string | null = null;
for (;;) {
@@ -123,13 +133,17 @@ async function readCatalogs(script: string, root: string): Promise<Catalog[]> {
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);
declared.map(async (d) => {
const path =
"file" in d
? join(buildRoot, d.file)
: await call(commands.gradlePublishedCatalog(d.artifact)).catch(() => null);
const text = path ? await readText(path) : null;
return text === null ? null : parseCatalog(d.name, path!, text);
}),
);
return catalogs.filter((c): c is Catalog => c !== null);
const complete = declared.every((d, i) => "file" in d || catalogs[i] !== null);
return { catalogs: catalogs.filter((c): c is Catalog => c !== null), complete };
}
const loaded = new Map<string, Catalog[]>();
@@ -148,11 +162,14 @@ function load(script: string): void {
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);
const p = readCatalogs(script, root).then(({ catalogs, complete }) => {
if (pending.get(script) !== p) return catalogs;
loaded.set(script, catalogs);
// Read again when the script is next opened, once a build has downloaded the catalog.
if (!complete) pending.delete(script);
return catalogs;
});
pending.set(script, p);
}
function open(entry: CatalogEntry, path: string) {