Skip to content
Closed
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
21 changes: 21 additions & 0 deletions millbun/integration/resources/invalid-bun-literal/build.mill
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//| mill-version: 1.1.5
//| mill-jvm-version: system
//| mvnDeps:
//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY

package build

import mill.*
import mill.bun.bun
import mill.scalajslib.*
import mill.scalajslib.api.*
import mill.scalajslib.bun.*

object app extends BunScalaJSModule {
override def moduleDir = build.moduleDir
def scalaVersion = "3.8.2"

override def moduleKind = Task { ModuleKind.CommonJSModule }
override def sources = Task.Sources(moduleDir / "src")
override def bunDeps = Task { Seq(bun"") }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
object App
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
object AppLocal
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
object AppPublished
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//| mill-version: 1.1.5
//| mill-jvm-version: system
//| mvnDeps:
//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY

package build

import mill.*
import mill.api.PathRef
import mill.scalajslib.*
import mill.scalajslib.api.*
import mill.scalajslib.bun.*

trait StubBunModule extends BunScalaJSModule {
override def moduleDir = build.moduleDir
def scalaVersion = "3.8.2"
override def moduleKind = Task { ModuleKind.CommonJSModule }
override def bunExecutable = Task { "stub-bun" }

override protected def runBun(
bunExe: String,
args: Seq[String],
cwd: os.Path,
env: Map[String, String]
): os.CommandResult = {
os.call(Seq("true"), cwd = cwd, env = env)
}
}

object localLib extends StubBunModule {
override def sources = Task.Sources(moduleDir / "local-lib")
override def bunOptionalDeps = Task { Seq("optional-local@^1.0.0") }
}

object publishedLib extends StubBunModule with BunPublishModule {
override def sources = Task.Sources(moduleDir / "published-lib")
override def bunDevDeps = Task { Seq("dev-only@^2.0.0") }
override def bunOptionalDeps = Task { Seq("optional-published@^3.0.0") }
}

object appLocal extends StubBunModule {
override def sources = Task.Sources(moduleDir / "app-local")
override def moduleDeps = Seq(localLib)
}

object appPublished extends StubBunModule {
override def sources = Task.Sources(moduleDir / "app-published")
override def runClasspath: T[Seq[PathRef]] = Task {
super.runClasspath() ++ Seq(publishedLib.jar())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
object LocalLib
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
object PublishedLib
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package mill.bun

import mill.api.PathRef
import mill.testkit.IntegrationTester
import utest.*

object BunDependencyManifestIntegrationTests extends TestSuite {
val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources"
val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH"))

private def tester(resource: String): IntegrationTester =
new IntegrationTester(
daemonMode = false,
workspaceSourcePath = resourceDir / resource,
millExecutable = millExe,
useInMemory = true
)

private def outputPath(tester: IntegrationTester, selector: String): os.Path =
tester.out(selector).value[PathRef].path

def tests: Tests = Tests {

test("invalid bun literal fails in build definitions") {
val tester = this.tester("invalid-bun-literal")
val res = tester.eval("app.bunDeps")
assert(!res.isSuccess)
}

test("published manifests include dev-only modules") {
val tester = this.tester("scalajs-dependency-manifests")
val res = tester.eval("publishedLib.jar")
assert(res.isSuccess)

val jar = outputPath(tester, "publishedLib.jar")
val manifest = BunManifest.readFromJar(jar)
assert(manifest.isDefined)
assert(manifest.get.dependencies.isEmpty)
assert(manifest.get.devDependencies == Map("dev-only" -> "^2.0.0"))
assert(manifest.get.optionalDependencies == Map("optional-published" -> "^3.0.0"))
}

test("local optional deps flow into generated package.json") {
val tester = this.tester("scalajs-dependency-manifests")
val res = tester.eval("appLocal.bunInstall")
assert(res.isSuccess)

val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "appLocal" / "bunInstall.dest" / "package.json"))
assert(packageJson("optionalDependencies").obj("optional-local").str == "^1.0.0")
}

test("classpath manifests flow dev and optional deps into generated package.json") {
val tester = this.tester("scalajs-dependency-manifests")
val res = tester.eval("appPublished.bunInstall")
assert(res.isSuccess)

val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "appPublished" / "bunInstall.dest" / "package.json"))
assert(packageJson("devDependencies").obj("dev-only").str == "^2.0.0")
assert(packageJson("optionalDependencies").obj("optional-published").str == "^3.0.0")
}
}
}
64 changes: 50 additions & 14 deletions millbun/src/mill/bun/BunDep.scala
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,56 @@ 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*) }
literalParts(sc) match
case Some(Seq(literal)) if isEmptyInterpolation(args) =>
validateLiteral(literal)
Expr(literal)
case _ =>
// Inside another macro (e.g., utest's Tests{}) the pattern may not match.
// Fall back to runtime string construction.
// Has interpolated parts, a non-literal StringContext, or runs inside another
// macro-generated context — build at runtime and skip compile-time validation.
'{ $sc.s($args*) }

private def literalParts(sc: Expr[StringContext])(using Quotes): Option[Seq[String]] =
import quotes.reflect.*

def extractRepeatedStrings(term: Term): Option[Seq[String]] =
term match
case Typed(Repeated(partTerms, _), _) => extractStringTerms(partTerms)
case Repeated(partTerms, _) => extractStringTerms(partTerms)
case Inlined(_, _, inner) => extractRepeatedStrings(inner)
case _ => None

def extractStringTerms(partTerms: Seq[Term]): Option[Seq[String]] =
partTerms.foldRight(Option(List.empty[String])) { (term, acc) =>
val part = term match
case Literal(StringConstant(value)) => Some(value)
case Inlined(_, _, inner) =>
inner match
case Literal(StringConstant(value)) => Some(value)
case _ => None
case _ => None
for
values <- acc
value <- part
yield value :: values
}

sc.asTerm.underlyingArgument match
case Apply(_, List(repeatedParts)) => extractRepeatedStrings(repeatedParts)
case _ => None

private def isEmptyInterpolation(args: Expr[Seq[Any]])(using Quotes): Boolean =
import quotes.reflect.*

def extractRepeatedArgs(term: Term): Option[Seq[Term]] =
term match
case Typed(Repeated(argTerms, _), _) => Some(argTerms)
case Repeated(argTerms, _) => Some(argTerms)
case Inlined(_, _, inner) => extractRepeatedArgs(inner)
case _ => None

extractRepeatedArgs(args.asTerm.underlyingArgument).contains(Nil)

private def validateLiteral(dep: String)(using Quotes): Unit =
import quotes.reflect.*
if dep.isEmpty then
Expand All @@ -54,6 +87,9 @@ private object BunDepMacro:
s"Invalid scoped package: '$dep'. Expected @scope/name or @scope/name@version"
)
val slashIdx = afterScope.indexOf('/')
val scopeName = afterScope.take(slashIdx)
if scopeName.isEmpty then
report.errorAndAbort(s"Invalid scoped package: '$dep'. Scope name is empty.")
val afterSlash = afterScope.drop(slashIdx + 1)
val nameOnly = if afterSlash.contains('@') then afterSlash.take(afterSlash.indexOf('@')) else afterSlash
if nameOnly.isEmpty then
Expand Down
17 changes: 17 additions & 0 deletions millbun/src/mill/bun/BunToolchainModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,23 @@ trait BunToolchainModule extends Module {
.map(PathRef(_))
}

/**
* Workspace-level lockfile committed by the developer.
*
* When present, `bunInstall` / `npmInstall` seed their install directory
* with this lockfile so Bun resolves deterministic versions. In CI,
* combine with `bunFrozenLockfile = true` to reject stale lockfiles.
*
* Declared as `Task.Input` so Mill re-evaluates when the file changes
* on disk and the sandbox checker allows reading from the workspace root.
*/
def bunWorkspaceLockfile: T[Option[PathRef]] = Task.Input {
Seq("bun.lock", "bun.lockb")
.map(name => BuildCtx.workspaceRoot / name)
.find(os.exists)
.map(PathRef(_))
}

/**
* Cross-compilation targets for `bun build --compile`.
* Values: "bun-linux-x64", "bun-darwin-arm64", "bun-windows-x64", etc.
Expand Down
46 changes: 46 additions & 0 deletions millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mill.javascriptlib
package bun

import mill.*
import mill.api.BuildCtx
import os.*
import mill.bun.BunToolchainModule

Expand Down Expand Up @@ -118,6 +119,11 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out
mkBunPackageJson()
copyBunWorkspaceConfigs()

// Seed workspace lockfile for deterministic resolution
bunWorkspaceLockfile().foreach { lock =>
os.copy.over(lock.path, dest / lock.path.last, createFolders = true)
}

runBun(
bunExecutable(),
Seq("install") ++ bunInstallArgs() ++ transitiveUnmanagedDeps().map(_.path.toString),
Expand All @@ -128,6 +134,41 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out
PathRef(dest)
}

/** Regenerate the workspace lockfile.
*
* Runs `bun install` (without `--frozen-lockfile`) in an ephemeral
* directory, then copies the resulting lockfile back to the workspace
* root so it can be committed for deterministic builds.
*
* {{{
* mill myApp.bunUpdateLock
* }}}
*/
def bunUpdateLock(): Command[PathRef] = Task.Command {
val dest = Task.dest
os.makeDir.all(dest)
mkBunPackageJson()
copyBunWorkspaceConfigs()

// Run install WITHOUT --frozen-lockfile to allow fresh resolution
val updateArgs = Seq("--save-text-lockfile", "--linker", bunLinker())
runBun(
bunExecutable(),
Seq("install") ++ updateArgs ++ transitiveUnmanagedDeps().map(_.path.toString),
cwd = dest,
env = bunToolEnv()
)

// Copy generated lockfile back to workspace root
val lockName = bunLockfiles().headOption.getOrElse("bun.lock")
val generated = dest / lockName
if (!os.exists(generated))
Task.fail(s"Expected lockfile '$lockName' was not generated in $dest")
val target = BuildCtx.workspaceRoot / lockName
os.copy.over(generated, target, createFolders = true)
PathRef(target)
}

/**
* Preserve Mill's compile sandbox preparation, but invoke TypeScript through
* Bun instead of a Node-shebang script.
Expand Down Expand Up @@ -327,6 +368,11 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out

outer.copyBunWorkspaceConfigs()

// Seed workspace lockfile for deterministic resolution
outer.bunWorkspaceLockfile().foreach { lock =>
os.copy.over(lock.path, dest / lock.path.last, createFolders = true)
}

runBun(
bunExecutable(),
Seq("install") ++ bunInstallArgs() ++ (outer.transitiveUnmanagedDeps() ++ transitiveUnmanagedDeps()).distinct.map(_.path.toString),
Expand Down
45 changes: 45 additions & 0 deletions millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out
os.copy.over(cfg.path, dest / cfg.path.last, createFolders = true)
}

// Seed workspace lockfile for deterministic resolution
bunWorkspaceLockfile().foreach { lock =>
os.copy.over(lock.path, dest / lock.path.last, createFolders = true)
}

mkBunPackageJson()

runBun(
Expand All @@ -209,6 +214,46 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out
PathRef(dest)
}

/** Regenerate the workspace lockfile.
*
* Runs `bun install` (without `--frozen-lockfile`) in an ephemeral
* directory, then copies the resulting lockfile back to the workspace
* root so it can be committed for deterministic builds.
*
* {{{
* mill myApp.bunUpdateLock
* }}}
*/
def bunUpdateLock(): Command[PathRef] = Task.Command {
val dest = Task.dest
os.makeDir.all(dest)

if (os.exists(npmRc().path)) os.copy.over(npmRc().path, dest / ".npmrc", createFolders = true)
bunfigFiles().foreach { cfg =>
os.copy.over(cfg.path, dest / cfg.path.last, createFolders = true)
}

mkBunPackageJson()

// Run install WITHOUT --frozen-lockfile to allow fresh resolution
val updateArgs = Seq("--save-text-lockfile", "--linker", bunLinker())
runBun(
bunExecutable(),
Seq("install") ++ updateArgs ++ transitiveUnmanagedDeps().map(_.path.toString),
cwd = dest,
env = bunEnv()
)

// Copy generated lockfile back to workspace root
val lockName = bunLockfiles().headOption.getOrElse("bun.lock")
val generated = dest / lockName
if (!os.exists(generated))
Task.fail(s"Expected lockfile '$lockName' was not generated in $dest")
val target = BuildCtx.workspaceRoot / lockName
os.copy.over(generated, target, createFolders = true)
PathRef(target)
}

private def resolvedBunConfigs: Task[Seq[PathRef]] = Task.Anon {
bunfigFiles()
}
Expand Down
7 changes: 2 additions & 5 deletions millbun/test/src/mill/bun/BunDepTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,7 @@ object BunDepTests extends TestSuite {
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
// Invalid literal coverage lives in integration tests so the interpolator
// is compiled in a normal build.mill context rather than inside another macro.
}
}
Loading