diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index d024fd0c2..472411a30 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -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> { + 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 }); diff --git a/crates/ide-sdk/src/libraries.rs b/crates/ide-sdk/src/libraries.rs index 0871150dc..40a61920b 100644 --- a/crates/ide-sdk/src/libraries.rs +++ b/crates/ide-sdk/src/libraries.rs @@ -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 { + 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 = + 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(); diff --git a/src-tauri/src/commands/build.rs b/src-tauri/src/commands/build.rs index 9f661d5bd..7daaf11ed 100644 --- a/src-tauri/src/commands/build.rs +++ b/src-tauri/src/commands/build.rs @@ -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> { + 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] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 16e6d465b..c3b6a0f04 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -337,6 +337,7 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src/bindings.ts b/src/bindings.ts index 17ffc6c10..156b12690 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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) => typedError(__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(__TAURI_INVOKE("gradle_published_catalog", { coordinates })), /** Where the direct dependency `dependency` of the Maven module `pom` is declared. */ mavenDependencyDeclaration: (pom: string, dependency: ArtifactKey) => typedError(__TAURI_INVOKE("maven_dependency_declaration", { pom, dependency })), /** diff --git a/src/features/build-tools/versionCatalog.test.ts b/src/features/build-tools/versionCatalog.test.ts index bc2c695a5..24968aa57 100644 --- a/src/features/build-tools/versionCatalog.test.ts +++ b/src/features/build-tools/versionCatalog.test.ts @@ -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 = { + "/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); + }); }); diff --git a/src/features/build-tools/versionCatalog.ts b/src/features/build-tools/versionCatalog.ts index 64330a120..803ad3801 100644 --- a/src/features/build-tools/versionCatalog.ts +++ b/src/features/build-tools/versionCatalog.ts @@ -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 { +/** + * 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 { 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(); @@ -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) {