Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions millbun/src/mill/bun/BunDep.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package mill.bun

/** String interpolator for Bun package dependencies.
*
* Validates the `name@version` format at compile time:
* {{{
* import mill.bun.bun
*
* bun"react@^19.0.0" // ok
* bun"@anthropic-ai/claude-agent-sdk@^0.2.90" // ok (scoped)
* bun"zod@^4.0.0" // ok
* bun"react" // ok (latest)
* bun"" // compile error
* }}}
*
* Returns a plain `String` so it's fully backward compatible with
* `npmDeps` / `bunDeps` declarations.
*/
extension (sc: StringContext)
inline def bun(inline args: Any*): String =
${ BunDepMacro.validateImpl('sc, 'args) }

private object BunDepMacro:
import scala.quoted.*

def validateImpl(sc: Expr[StringContext], args: Expr[Seq[Any]])(using Quotes): Expr[String] =
import quotes.reflect.*

sc match
case '{ StringContext(${ Varargs(parts) }*) } =>
// For a no-interpolation literal like bun"react@19.0.0", parts has 1 element
parts match
case Seq(Expr(literal: String)) if args.matches('{ Seq() }) || args.matches('{ Nil }) =>
validateLiteral(literal)
Expr(literal)
case _ =>
// Has interpolated parts — build at runtime, skip compile-time validation
'{ $sc.s($args*) }
case _ =>
// Inside another macro (e.g., utest's Tests{}) the pattern may not match.
// Fall back to runtime string construction.
'{ $sc.s($args*) }

private def validateLiteral(dep: String)(using Quotes): Unit =
import quotes.reflect.*
if dep.isEmpty then
report.errorAndAbort("bun dependency cannot be empty. Use bun\"package@version\" format.")
// Validate package name format
val name = if dep.startsWith("@") then
// Scoped: @scope/name or @scope/name@version
val afterScope = dep.drop(1)
if !afterScope.contains('/') then
report.errorAndAbort(
s"Invalid scoped package: '$dep'. Expected @scope/name or @scope/name@version"
)
val slashIdx = afterScope.indexOf('/')
val afterSlash = afterScope.drop(slashIdx + 1)
val nameOnly = if afterSlash.contains('@') then afterSlash.take(afterSlash.indexOf('@')) else afterSlash
if nameOnly.isEmpty then
report.errorAndAbort(s"Invalid scoped package: '$dep'. Package name is empty after scope.")
dep
else
// Unscoped: name or name@version
val nameOnly = if dep.contains('@') then dep.take(dep.indexOf('@')) else dep
if nameOnly.isEmpty then
report.errorAndAbort(s"Invalid package: '$dep'. Package name cannot be empty.")
dep
82 changes: 82 additions & 0 deletions millbun/src/mill/bun/BunManifest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package mill.bun

import java.util.jar.JarFile

/** Bun dependency manifest embedded in published JARs.
*
* When a Scala.js library declares JS package dependencies via `npmDeps`,
* this manifest is generated and included in the JAR at publish time.
* Consumer builds scan classpath JARs for these manifests and merge them
* into their `package.json`, making JS deps transitive — just like
* Coursier resolves JVM deps from POMs.
*
* Layout inside JAR:
* {{{
* META-INF/bun/bun-dependencies.json — dependency manifest
* }}}
*/
final case class BunManifest(
dependencies: Map[String, String],
devDependencies: Map[String, String],
optionalDependencies: Map[String, String]
)

object BunManifest:
val ManifestPath = "META-INF/bun/bun-dependencies.json"

val empty: BunManifest = BunManifest(Map.empty, Map.empty, Map.empty)

/** Serialize manifest to JSON. */
def toJson(manifest: BunManifest): ujson.Obj =
val obj = ujson.Obj(
"dependencies" -> ujson.Obj.from(manifest.dependencies.map((k, v) => k -> ujson.Str(v))),
"devDependencies" -> ujson.Obj.from(manifest.devDependencies.map((k, v) => k -> ujson.Str(v)))
)
if manifest.optionalDependencies.nonEmpty then
obj("optionalDependencies") = ujson.Obj.from(
manifest.optionalDependencies.map((k, v) => k -> ujson.Str(v))
)
obj

/** Deserialize manifest from JSON. */
def fromJson(json: ujson.Value): BunManifest =
val obj = json.obj
def readDeps(key: String): Map[String, String] =
obj.get(key).map(_.obj.map((k, v) => k -> v.str).toMap).getOrElse(Map.empty)
BunManifest(
dependencies = readDeps("dependencies"),
devDependencies = readDeps("devDependencies"),
optionalDependencies = readDeps("optionalDependencies")
)

/** Read a manifest from inside a JAR file. Returns None if no manifest is present. */
def readFromJar(jarPath: os.Path): Option[BunManifest] =
if !os.exists(jarPath) then return None
val jar = new JarFile(jarPath.toIO)
try
val entry = jar.getEntry(ManifestPath)
if entry == null then None
else
val is = jar.getInputStream(entry)
try Some(fromJson(ujson.read(is)))
finally is.close()
catch case _: Exception => None
finally jar.close()

/** Read a manifest from an unpacked directory (e.g., classes output). */
def readFromDir(dirPath: os.Path): Option[BunManifest] =
val manifestFile = dirPath / os.RelPath(ManifestPath)
if os.exists(manifestFile) then
try Some(fromJson(ujson.read(os.read(manifestFile))))
catch case _: Exception => None
else None

/** Merge multiple manifests into one. Later entries override earlier ones for the same package. */
def merge(manifests: Seq[BunManifest]): BunManifest =
manifests.foldLeft(empty) { (acc, m) =>
BunManifest(
dependencies = acc.dependencies ++ m.dependencies,
devDependencies = acc.devDependencies ++ m.devDependencies,
optionalDependencies = acc.optionalDependencies ++ m.optionalDependencies
)
}
53 changes: 53 additions & 0 deletions millbun/src/mill/scalajslib/bun/BunPublishModule.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package mill.scalajslib
package bun

import mill.*
import mill.bun.{BunManifest, BunToolchainModule}

/** Opt-in trait for Scala.js libraries that publish JARs with embedded bun dependency manifests.
*
* Mix this into modules whose JARs should carry `META-INF/bun/bun-dependencies.json`
* so that consumers automatically resolve JS package dependencies via `classpathBunDeps`.
*
* {{{
* object myLib extends BunScalaJSModule with BunPublishModule {
* def bunDeps = Task { Seq(bun"react@^19.0.0") }
* }
* }}}
*/
trait BunPublishModule extends BunScalaJSModule {

/** Generate bun dependency manifest + lockfile for inclusion in published JARs.
*
* The manifest declares this library's JS package requirements so that
* consumers automatically get them via `classpathBunDeps`. The lockfile
* is embedded alongside for deterministic resolution seeding.
*/
def bunDependencyManifest: T[PathRef] = Task {
val allDeps = (npmDeps() ++ bunDeps()).map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap
val allDevDeps = (npmDevDeps() ++ bunDevDeps()).map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap
val optDeps = bunOptionalDeps().map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap
val manifest = BunManifest(allDeps, allDevDeps, optDeps)
val metaDir = Task.dest / "META-INF" / "bun"
os.write(metaDir / "bun-dependencies.json", BunManifest.toJson(manifest).render(indent = 2), createFolders = true)
PathRef(Task.dest)
}

/** Resource paths that include the bun dependency manifest.
*
* When this module declares any JS deps, the manifest
* is embedded in the published JAR.
*/
def bunDependencyManifestResources: T[Seq[PathRef]] = Task {
if npmDeps().nonEmpty || bunDeps().nonEmpty ||
npmDevDeps().nonEmpty || bunDevDeps().nonEmpty ||
bunOptionalDeps().nonEmpty
then
Seq(bunDependencyManifest())
else Seq.empty
}

override def resources: T[Seq[PathRef]] = Task {
super.resources() ++ bunDependencyManifestResources()
}
}
73 changes: 70 additions & 3 deletions millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ package bun
import mill.*
import mill.api.BuildCtx
import mill.api.JsonFormatters.given
import mill.bun.BunToolchainModule
import mill.bun.{BunManifest, BunToolchainModule}
import mill.javalib.JavaModule
import mill.scalajslib.*
import mill.scalajslib.api.*
Expand All @@ -19,6 +19,27 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out
/** Dev-only JS packages for bundling or local tooling. */
def npmDevDeps: T[Seq[String]] = Task { Seq.empty }

/** JS packages needed by linked Scala.js output.
*
* Use the `bun"pkg@version"` string interpolator for compile-time validation:
* {{{
* def bunDeps = Task { Seq(
* bun"@anthropic-ai/claude-agent-sdk@^0.2.90",
* bun"zod@^4.0.0"
* )}
* }}}
*
* Both `bunDeps` and `npmDeps` are merged into `transitiveNpmDeps` —
* use whichever you prefer. They are independent (no delegation).
*/
def bunDeps: T[Seq[String]] = Task { Seq.empty }

/** Dev-only JS packages for bundling or local tooling.
*
* Independent of `npmDevDeps` — both are merged into `transitiveNpmDevDeps`.
*/
def bunDevDeps: T[Seq[String]] = Task { Seq.empty }

/** Local tarballs / package directories. */
def unmanagedDeps: T[Seq[PathRef]] = Task { Seq.empty }

Expand Down Expand Up @@ -47,17 +68,54 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out
}

def transitiveNpmDeps: T[Seq[String]] = Task {
Task.traverse(recursiveBunModuleDeps)(_.npmDeps)().flatten ++ npmDeps()
val moduleNpm = Task.traverse(recursiveBunModuleDeps)(_.npmDeps)().flatten
val moduleBun = Task.traverse(recursiveBunModuleDeps)(_.bunDeps)().flatten
val jarDeps = classpathBunDeps()
moduleNpm ++ moduleBun ++ jarDeps ++ npmDeps() ++ bunDeps()
}

def transitiveNpmDevDeps: T[Seq[String]] = Task {
Task.traverse(recursiveBunModuleDeps)(_.npmDevDeps)().flatten ++ npmDevDeps()
val moduleNpm = Task.traverse(recursiveBunModuleDeps)(_.npmDevDeps)().flatten
val moduleBun = Task.traverse(recursiveBunModuleDeps)(_.bunDevDeps)().flatten
val jarDevDeps = classpathBunDevDeps()
moduleNpm ++ moduleBun ++ jarDevDeps ++ npmDevDeps() ++ bunDevDeps()
}

def transitiveUnmanagedDeps: T[Seq[PathRef]] = Task {
Task.traverse(recursiveBunModuleDeps)(_.unmanagedDeps)().flatten ++ unmanagedDeps()
}

/** Optional JS packages — installed if available, not fatal if missing. */
def bunOptionalDeps: T[Seq[String]] = Task { Seq.empty }

// ---------------------------------------------------------------------------
// Classpath manifest scanning — reads bun-dependencies.json from dependency JARs
// ---------------------------------------------------------------------------

/** Scan classpath JARs for embedded bun dependency manifests. */
def classpathBunDeps: T[Seq[String]] = Task {
classpathBunManifests().flatMap(_.dependencies).map { case (name, version) => s"$name@$version" }
}

/** Scan classpath JARs for embedded bun dev-dependency manifests. */
def classpathBunDevDeps: T[Seq[String]] = Task {
classpathBunManifests().flatMap(_.devDependencies).map { case (name, version) => s"$name@$version" }
}

/** Scan classpath JARs for embedded bun optional-dependency manifests. */
def classpathBunOptionalDeps: T[Seq[String]] = Task {
classpathBunManifests().flatMap(_.optionalDependencies).map { case (name, version) => s"$name@$version" }
}

private def classpathBunManifests: Task[Seq[BunManifest]] = Task.Anon {
runClasspath().flatMap { ref =>
val path = ref.path
if os.exists(path) && path.ext == "jar" then BunManifest.readFromJar(path).toSeq
else if os.isDir(path) then BunManifest.readFromDir(path).toSeq
else Nil
}
}

/** Extra package.json fields not modeled by this scaffold. */
def bunPackageJsonExtras: T[ujson.Obj] = Task { ujson.Obj() }

Expand Down Expand Up @@ -98,15 +156,24 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out
if (name.nonEmpty) name.split('.').last.replace('.', '-') else "app"
}

def transitiveBunOptionalDeps: T[Seq[String]] = Task {
val moduleOptional = Task.traverse(recursiveBunModuleDeps)(_.bunOptionalDeps)().flatten
val jarOptional = classpathBunOptionalDeps()
moduleOptional ++ jarOptional ++ bunOptionalDeps()
}

private def mkBunPackageJson: Task[Unit] = Task.Anon {
val dest = Task.dest
val allOptional = transitiveBunOptionalDeps().map(BunToolchainModule.splitDep)
val base = ujson.Obj(
"name" -> defaultPackageName,
"private" -> true,
"version" -> "0.0.0",
"dependencies" -> ujson.Obj.from(transitiveNpmDeps().map(BunToolchainModule.splitDep)),
"devDependencies" -> ujson.Obj.from(transitiveNpmDevDeps().map(BunToolchainModule.splitDep))
)
if allOptional.nonEmpty then
base("optionalDependencies") = ujson.Obj.from(allOptional)

val packageType =
moduleKind() match {
Expand Down
59 changes: 59 additions & 0 deletions millbun/test/src/mill/bun/BunDepTests.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package mill.bun

import utest._

object BunDepTests extends TestSuite {
def tests: Tests = Tests {

test("simple package with version") {
val dep = bun"react@^19.0.0"
assert(dep == "react@^19.0.0")
}

test("scoped package with version") {
val dep = bun"@anthropic-ai/claude-agent-sdk@^0.2.90"
assert(dep == "@anthropic-ai/claude-agent-sdk@^0.2.90")
}

test("package without version (latest)") {
val dep = bun"zod"
assert(dep == "zod")
}

test("scoped package without version") {
val dep = bun"@types/node"
assert(dep == "@types/node")
}

test("package with exact version") {
val dep = bun"react@19.1.1"
assert(dep == "react@19.1.1")
}

test("package with tilde range") {
val dep = bun"lodash@~4.17.0"
assert(dep == "lodash@~4.17.0")
}

test("returns plain String type") {
val dep: String = bun"react@19.0.0"
assert(dep.isInstanceOf[String])
}

test("works in Seq for bunDeps") {
val deps: Seq[String] = Seq(
bun"@anthropic-ai/claude-agent-sdk@^0.2.90",
bun"@openai/codex-sdk@^0.118.0",
bun"zod@^4.0.0"
)
assert(deps.length == 3)
assert(deps.head.startsWith("@anthropic-ai"))
}

// Note: compile-time validation errors can't be tested at runtime.
// The following would fail to compile:
// bun"" // empty
// bun"@/bad" // empty name after scope
// bun"@noSlash" // missing slash in scoped name
}
}
Loading
Loading