From cfd4924af2ac45fa3ce47135450448af0af4917c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20=C5=A0est=C3=A1k?= Date: Sat, 21 Oct 2023 18:50:27 +0200 Subject: [PATCH] Add support for multiple subprojects --- index.ts | 83 ++++++++++++++++++++++++++++++++++-------- test/plugin.test.ts | 88 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 14 deletions(-) diff --git a/index.ts b/index.ts index 5bfcf5d..0a2b9ad 100644 --- a/index.ts +++ b/index.ts @@ -2,8 +2,8 @@ import { spawn, SpawnOptions } from "child_process"; import type { Plugin as VitePlugin } from "vite"; // Utility to invoke a given sbt task and fetch its output -function printSbtTask(task: string, cwd?: string): Promise { - const args = ["--batch", "-no-colors", "-Dsbt.supershell=false", `print ${task}`]; +function printSbtTasks(tasks: Array, cwd?: string): Promise> { + const args = ["--batch", "-no-colors", "-Dsbt.supershell=false", ...tasks.map(task => `print ${task}`)]; const options: SpawnOptions = { cwd: cwd, stdio: ['ignore', 'pipe', 'inherit'], @@ -28,24 +28,68 @@ function printSbtTask(task: string, cwd?: string): Promise { if (code !== 0) reject(new Error(`sbt invocation for Scala.js compilation failed with exit code ${code}.`)); else - resolve(fullOutput.trimEnd().split('\n').at(-1)!); + resolve(fullOutput.trimEnd().split('\n').slice(-tasks.length)); }); }); } +export interface Subproject { + projectID: string | null, + uriPrefix: string, +} + export interface ScalaJSPluginOptions { cwd?: string, projectID?: string, uriPrefix?: string, + subprojects?: Array, } -export default function scalaJSPlugin(options: ScalaJSPluginOptions = {}): VitePlugin { - const { cwd, projectID, uriPrefix } = options; +function extractSubprojects(options: ScalaJSPluginOptions): Array { + if (options.subprojects) { + if (options.projectID || options.uriPrefix) { + throw new Error("If you specify subprojects, you cannot specify projectID / uriPrefix") + } + return options.subprojects; + } else { + return [ + { + projectID: options.projectID || null, + uriPrefix: options.uriPrefix || 'scalajs', + } + ]; + } +} + +function mapBy(a: Array, f: ((item: T) => K), itemName: string): Map { + const out = new Map(); + a.forEach((item) => { + const key: K = f(item); + if (out.has(key)) { + throw Error("Duplicate " + itemName + " " + key + "."); + } else { + out.set(key, item); + } + }); + return out; +} + +function zip(a: Array, b: Array): Array<[T, U]> { + if (a.length != b.length) { + throw new Error("length mismatch: " + a.length + " ~= " + b.length) + } + return a.map((item, i) => [item, b[i]]); +} - const fullURIPrefix = uriPrefix ? (uriPrefix + ':') : 'scalajs:'; +export default function scalaJSPlugin(options: ScalaJSPluginOptions = {}): VitePlugin { + const { cwd } = options; + const subprojects = extractSubprojects(options); + // This also checks for duplicates + const spByProjectID = mapBy(subprojects, (p) => p.projectID, "projectID") + const spByUriPrefix = mapBy(subprojects, (p) => p.uriPrefix, "uriPrefix") let isDev: boolean | undefined = undefined; - let scalaJSOutputDir: string | undefined = undefined; + let scalaJSOutputDirs: Map | undefined = undefined; return { name: "scalajs:sbt-scalajs-plugin", @@ -61,20 +105,31 @@ export default function scalaJSPlugin(options: ScalaJSPluginOptions = {}): ViteP throw new Error("configResolved must be called before buildStart"); const task = isDev ? "fastLinkJSOutput" : "fullLinkJSOutput"; - const projectTask = projectID ? `${projectID}/${task}` : task; - scalaJSOutputDir = await printSbtTask(projectTask, cwd); + const projectTasks = subprojects.map( p => + p.projectID ? `${p.projectID}/${task}` : task + ); + const scalaJSOutputDirsArray = await printSbtTasks(projectTasks, cwd); + scalaJSOutputDirs = new Map(zip( + subprojects.map(p => p.uriPrefix), + scalaJSOutputDirsArray + )) }, // standard Rollup resolveId(source, importer, options) { - if (scalaJSOutputDir === undefined) + if (scalaJSOutputDirs === undefined) throw new Error("buildStart must be called before resolveId"); - - if (!source.startsWith(fullURIPrefix)) + const colonPos = source.indexOf(':'); + if (colonPos == -1) { + return null; + } + const subprojectUriPrefix = source.substr(0, colonPos); + const outDir = scalaJSOutputDirs.get(subprojectUriPrefix) + if (outDir == null) return null; - const path = source.substring(fullURIPrefix.length); + const path = source.substring(subprojectUriPrefix.length + 1); - return `${scalaJSOutputDir}/${path}`; + return `${outDir}/${path}`; }, }; } diff --git a/test/plugin.test.ts b/test/plugin.test.ts index f48f503..6cea7d7 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -18,6 +18,23 @@ function normalizeSlashes(path: string | null): string | null { return path === null ? null : path.replace(/\\/g, '/'); } +function testBothModes( + testFunction: (d: string, func: () => Promise, options: TestOptions) => void, + description: string, + f: (mode: string, suffix: string) => Promise, + testOptions: TestOptions, +) { + testFunction ||= it + const MODES = [["production", MODE_PRODUCTION, "opt"], ["development", MODE_DEVELOPMENT, "fastopt"]] + MODES.forEach( ([modeName, mode, suffix]) => { + testFunction( + description + "(" + modeName + ")", + async () => await f(mode, suffix), + testOptions, + ) + }) +} + const MODE_DEVELOPMENT = 'development'; const MODE_PRODUCTION = 'production'; @@ -102,6 +119,77 @@ describe("scalaJSPlugin", () => { .toBeNull(); }, testOptions); + testBothModes(it, "works with a project with subprojects", async (mode, suffix) => { + const [plugin, fakePluginContext] = setup({ + subprojects: [ + { + projectID: "otherProject", + uriPrefix: "foo", + }, + { + projectID: null, + uriPrefix: "bar", + }, + ] + }); + + await plugin.configResolved.call(undefined, { mode: mode }); + await plugin.buildStart.call(fakePluginContext, {}); + + expect(normalizeSlashes(await plugin.resolveId.call(fakePluginContext, 'foo:main.js'))) + .toContain('/testproject/other-project/target/scala-3.2.2/otherproject-' + suffix + '/main.js'); + expect(normalizeSlashes(await plugin.resolveId.call(fakePluginContext, 'bar:main.js'))) + .toContain('/testproject/target/scala-3.2.2/testproject-' + suffix + '/main.js'); + + expect(await plugin.resolveId.call(fakePluginContext, 'scalajs/main.js')) + .toBeNull(); + }, testOptions); + + it.fails("with duplicate projectID", async () => { + setup({ + subprojects: [ + { + projectID: "otherProject", + uriPrefix: "foo", + }, + { + projectID: "otherProject", + uriPrefix: "bar", + }, + ] + }); + }); + + it.fails("with duplicate uriPrefix", async () => { + setup({ + subprojects: [ + { + projectID: "otherProject", + uriPrefix: "foo", + }, + { + projectID: null, + uriPrefix: "foo", + }, + ] + }); + }); + + it.fails("when both projectID and subproojects are specified", async () => { + setup({ + projectID: "xxx", + subprojects: [] + }); + }); + + it.fails("when both uriPrefix and subproojects are specified", async () => { + setup({ + uriPrefix: "xxx", + subprojects: [] + }); + }); + + it("does not work with a project that does not link", async () => { const [plugin, fakePluginContext] = setup({ projectID: "invalidProject",