Skip to content
Open
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ cellar search [--module <name>] <query>
- **Mill / sbt**: `--module` is required (e.g. `--module lib`, `--module core`)
- **scala-cli**: `--module` is not supported — omit it

Add `--test` to query the test-scope classpath (test dependencies and test sources):

- **sbt**: resolves `<module>/Test/fullClasspath`
- **scala-cli**: compiles with `--test`
- **Mill**: not supported — test code is a separate module, query it directly (e.g. `--module foo.test`)

The classpath is cached after the first run. Use `--no-cache` to force re-extraction.

### External commands
Expand Down Expand Up @@ -149,6 +155,7 @@ cellar get-external org.typelevel:cats-core_3:latest cats.Monad
|---|---|---|
| `--module <name>`, `-m` | project commands | Build module name (required for Mill/sbt) |
| `--no-cache` | project commands | Skip classpath cache, re-extract from build tool |
| `--test` | project commands | Use the test-scope classpath (sbt/scala-cli; not supported for Mill) |
| `--java-home <path>` | all | Use a specific JDK for JRE classpath |
| `-r`, `--repository <url>` | external commands | Extra Maven repository URL (repeatable) |
| `-l`, `--limit <N>` | `list`, `list-external`, `search`, `search-external` | Max results (default: 50) |
Expand Down Expand Up @@ -294,6 +301,7 @@ For querying the current project's code and dependencies (auto-detects build too

- Mill/sbt projects: `--module` is required (e.g. `--module lib`, `--module core`)
- scala-cli projects: `--module` is not supported (omit it)
- `--test`: query the test-scope classpath (sbt/scala-cli; for Mill, use the test module directly, e.g. `--module foo.test`)
- `--no-cache`: skip classpath cache, re-extract from build tool
- `--java-home`: override JRE classpath

Expand Down
20 changes: 12 additions & 8 deletions cli/src/cellar/cli/CellarApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ object CellarApp
private val noCacheOpt: Opts[Boolean] =
Opts.flag("no-cache", "Skip classpath cache (re-extract from build tool)").orFalse

private val testScopeOpt: Opts[Boolean] =
Opts.flag("test", "Use the test-scope classpath (sbt/scala-cli)").orFalse

private val hideInheritedOpt: Opts[Boolean] =
Opts.flag("hide-inherited", "Show only members declared on the type itself").orFalse

Expand All @@ -92,24 +95,25 @@ object CellarApp

private val getSubcmd: Opts[IO[ExitCode]] =
Opts.subcommand("get", "Fetch symbol info from the current project") {
(symbolArg, moduleOpt, memberLimitOpt, hideInheritedOpt, groupInheritedOpt, javaHomeOpt, noCacheOpt).mapN {
(fqn, module, limit, hideInherited, groupInherited, javaHome, noCache) =>
ProjectGetHandler.run(fqn, module, javaHome, noCache, limit, hideInherited, groupInherited)
(symbolArg, moduleOpt, memberLimitOpt, hideInheritedOpt, groupInheritedOpt, javaHomeOpt, noCacheOpt, testScopeOpt).mapN {
(fqn, module, limit, hideInherited, groupInherited, javaHome, noCache, testScope) =>
ProjectGetHandler.run(fqn, module, javaHome, noCache, limit, hideInherited, groupInherited, testScope = testScope)
}
}

private val listSubcmd: Opts[IO[ExitCode]] =
Opts.subcommand("list", "List symbols in a package or class from the current project") {
(symbolArg, moduleOpt, limitOpt, javaHomeOpt, noCacheOpt).mapN { (fqn, module, limit, javaHome, noCache) =>
ProjectListHandler.run(fqn, module, limit, javaHome, noCache)
(symbolArg, moduleOpt, limitOpt, javaHomeOpt, noCacheOpt, testScopeOpt).mapN {
(fqn, module, limit, javaHome, noCache, testScope) =>
ProjectListHandler.run(fqn, module, limit, javaHome, noCache, testScope = testScope)
}
}

private val searchSubcmd: Opts[IO[ExitCode]] =
Opts.subcommand("search", "Substring search for symbol names in the current project") {
(Opts.argument[String]("query"), moduleOpt, limitOpt, javaHomeOpt, noCacheOpt).mapN {
(query, module, limit, javaHome, noCache) =>
ProjectSearchHandler.run(query, module, limit, javaHome, noCache)
(Opts.argument[String]("query"), moduleOpt, limitOpt, javaHomeOpt, noCacheOpt, testScopeOpt).mapN {
(query, module, limit, javaHome, noCache, testScope) =>
ProjectSearchHandler.run(query, module, limit, javaHome, noCache, testScope = testScope)
}
}

Expand Down
5 changes: 5 additions & 0 deletions lib/src/cellar/CellarError.scala
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ object CellarError:
override def getMessage: String =
s"--module is not supported for ${toolName(tool)} projects."

final case class TestScopeNotSupported(tool: BuildToolKind) extends CellarError:
override def getMessage: String =
s"--test is not supported for ${toolName(tool)} projects: test code lives in a separate module. " +
"Query it directly, e.g. --module foo.test."

final case class CompilationFailed(tool: BuildToolKind, stderr: String) extends CellarError:
override def getMessage: String =
s"Compilation failed:\n$stderr"
Expand Down
8 changes: 6 additions & 2 deletions lib/src/cellar/build/BuildTool.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ import fs2.io.file.Path

trait BuildTool:
def kind: BuildToolKind
def compile(module: Option[String]): IO[Unit]
def extractClasspath(module: Option[String]): IO[List[Path]]
def compile(module: Option[String], testScope: Boolean): IO[Unit]
def extractClasspath(module: Option[String], testScope: Boolean): IO[List[Path]]
def fingerprintFiles: IO[List[Path]]

/** Build tools without a Compile/Test scope axis reject `--test`. */
protected def rejectTestScope(testScope: Boolean): IO[Unit] =
IO.raiseWhen(testScope)(CellarError.TestScopeNotSupported(kind))

protected def requireModule(module: Option[String]): IO[String] =
module match
case Some(m) => IO.pure(m)
Expand Down
8 changes: 4 additions & 4 deletions lib/src/cellar/build/MillBuildTool.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ import fs2.io.file.{Files, Path}
class MillBuildTool(cwd: Path, config: MillConfig) extends BuildTool:
def kind: BuildToolKind = BuildToolKind.Mill

def compile(module: Option[String]): IO[Unit] =
requireModule(module).flatMap { mod =>
def compile(module: Option[String], testScope: Boolean): IO[Unit] =
rejectTestScope(testScope) >> requireModule(module).flatMap { mod =>
ProcessRunner.run(config.binary, List(s"$mod.compile"), Some(cwd)).flatMap { result =>
if result.exitCode == 0 then IO.unit
else IO.raiseError(CellarError.CompilationFailed(BuildToolKind.Mill, result.stderr))
}
}

def extractClasspath(module: Option[String]): IO[List[Path]] =
requireModule(module).flatMap { mod =>
def extractClasspath(module: Option[String], testScope: Boolean): IO[List[Path]] =
rejectTestScope(testScope) >> requireModule(module).flatMap { mod =>
for
compileResult <- ProcessRunner.run(config.binary, List("show", s"$mod.compile"), Some(cwd))
_ <- checkCompileResult(compileResult, mod)
Expand Down
19 changes: 10 additions & 9 deletions lib/src/cellar/build/ProjectClasspathProvider.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,32 +13,33 @@ object ProjectClasspathProvider:
module: Option[String],
jreClasspath: Classpath,
noCache: Boolean,
config: Config = Config.global
config: Config = Config.global,
testScope: Boolean = false
): Resource[IO, (Context, Classpath)] =
Resource.eval(resolveClasspath(cwd, module, noCache, config)).flatMap { paths =>
Resource.eval(resolveClasspath(cwd, module, noCache, config, testScope)).flatMap { paths =>
ContextResource.make(paths, jreClasspath)
}

private def resolveClasspath(cwd: Path, module: Option[String], noCache: Boolean, config: Config): IO[List[Path]] =
private def resolveClasspath(cwd: Path, module: Option[String], noCache: Boolean, config: Config, testScope: Boolean): IO[List[Path]] =
BuildToolDetector.detectKind(cwd).flatMap { kind =>
val buildTool = instantiate(kind, cwd, config)
val useCache = kind != BuildToolKind.ScalaCli && !noCache

if useCache then cachedFlow(buildTool, module, cwd)
else buildTool.extractClasspath(module)
if useCache then cachedFlow(buildTool, module, cwd, testScope)
else buildTool.extractClasspath(module, testScope)
}

private def cachedFlow(buildTool: BuildTool, module: Option[String], cwd: Path): IO[List[Path]] =
private def cachedFlow(buildTool: BuildTool, module: Option[String], cwd: Path, testScope: Boolean): IO[List[Path]] =
val cache = ClasspathCache(cwd)
val moduleKey = module.getOrElse("")
val moduleKey = s"${module.getOrElse("")}${if testScope then "/test" else ""}"

for
fingerFiles <- buildTool.fingerprintFiles
hash <- BuildFingerprint.compute(fingerFiles, moduleKey)
cached <- cache.get(hash)
paths <- cached match
case Some(paths) => buildTool.compile(module).as(paths)
case None => buildTool.extractClasspath(module).flatTap(paths => cache.put(hash, paths))
case Some(paths) => buildTool.compile(module, testScope).as(paths)
case None => buildTool.extractClasspath(module, testScope).flatTap(paths => cache.put(hash, paths))
yield paths

private def instantiate(kind: BuildToolKind, cwd: Path, config: Config): BuildTool = kind match
Expand Down
10 changes: 6 additions & 4 deletions lib/src/cellar/build/SbtBuildTool.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,20 @@ import fs2.io.file.{Files, Path}
class SbtBuildTool(cwd: Path, config: SbtConfig) extends BuildTool:
def kind: BuildToolKind = BuildToolKind.Sbt

def compile(module: Option[String]): IO[Unit] =
def compile(module: Option[String], testScope: Boolean): IO[Unit] =
requireModule(module).flatMap { mod =>
ProcessRunner.run(config.binary, config.effectiveExtraArgs ::: List(s"$mod/compile"), Some(cwd)).flatMap { result =>
val task = if testScope then s"$mod/Test/compile" else s"$mod/compile"
ProcessRunner.run(config.binary, config.effectiveExtraArgs ::: List(task), Some(cwd)).flatMap { result =>
IO.raiseUnless(result.exitCode == 0)(
CellarError.CompilationFailed(BuildToolKind.Sbt, extractErrors(result.stdout, result.stderr))
)
}
}

def extractClasspath(module: Option[String]): IO[List[Path]] =
def extractClasspath(module: Option[String], testScope: Boolean): IO[List[Path]] =
requireModule(module).flatMap { mod =>
ProcessRunner.run(config.binary, config.effectiveExtraArgs ::: List(s"export $mod/Compile/fullClasspath"), Some(cwd)).flatMap { result =>
val scope = if testScope then "Test" else "Compile"
ProcessRunner.run(config.binary, config.effectiveExtraArgs ::: List(s"export $mod/$scope/fullClasspath"), Some(cwd)).flatMap { result =>
if result.exitCode != 0 then
IO.raiseError(CellarError.CompilationFailed(BuildToolKind.Sbt, extractErrors(result.stdout, result.stderr)))
else
Expand Down
11 changes: 7 additions & 4 deletions lib/src/cellar/build/ScalaCliBuildTool.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ import fs2.io.file.Path
class ScalaCliBuildTool(cwd: Path) extends BuildTool:
def kind: BuildToolKind = BuildToolKind.ScalaCli

def compile(module: Option[String]): IO[Unit] =
def compile(module: Option[String], testScope: Boolean): IO[Unit] =
rejectModule(module) >>
ProcessRunner.run("scala-cli", List("compile", "."), Some(cwd)).flatMap { result =>
ProcessRunner.run("scala-cli", List("compile") ::: testFlag(testScope) ::: List("."), Some(cwd)).flatMap { result =>
if result.exitCode == 0 then IO.unit
else IO.raiseError(CellarError.CompilationFailed(BuildToolKind.ScalaCli, result.stderr))
}

def extractClasspath(module: Option[String]): IO[List[Path]] =
def extractClasspath(module: Option[String], testScope: Boolean): IO[List[Path]] =
rejectModule(module) >>
ProcessRunner.run("scala-cli", List("compile", "--print-classpath", "."), Some(cwd)).flatMap { result =>
ProcessRunner.run("scala-cli", List("compile", "--print-classpath") ::: testFlag(testScope) ::: List("."), Some(cwd)).flatMap { result =>
if result.exitCode != 0 then
IO.raiseError(CellarError.CompilationFailed(BuildToolKind.ScalaCli, result.stderr))
else
Expand All @@ -28,6 +28,9 @@ class ScalaCliBuildTool(cwd: Path) extends BuildTool:

def fingerprintFiles: IO[List[Path]] = IO.pure(Nil)

private def testFlag(testScope: Boolean): List[String] =
if testScope then List("--test") else Nil

private def rejectModule(module: Option[String]): IO[Unit] =
module match
case Some(_) => IO.raiseError(CellarError.ModuleNotSupported(BuildToolKind.ScalaCli))
Expand Down
5 changes: 3 additions & 2 deletions lib/src/cellar/handlers/ProjectGetHandler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ object ProjectGetHandler:
hideInherited: Boolean = false,
groupInherited: Boolean = false,
cwd: Option[Path] = None,
config: Config = Config.global
config: Config = Config.global,
testScope: Boolean = false
)(using Console[IO]): IO[ExitCode] =
ProjectHandler.run(javaHome, cwd, module, noCache, config) { (ctx, classpath, _) =>
ProjectHandler.run(javaHome, cwd, module, noCache, config, testScope) { (ctx, classpath, _) =>
given Context = ctx
GetHandler.runCore(fqn, classpath, coord = None, limit, hideInherited, groupInherited)
}
5 changes: 3 additions & 2 deletions lib/src/cellar/handlers/ProjectHandler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ object ProjectHandler:
cwd: Option[Path],
module: Option[String],
noCache: Boolean,
config: Config = Config.global
config: Config = Config.global,
testScope: Boolean = false
)(body: (Context, Classpath, Classpath) => IO[ExitCode])(using Console[IO]): IO[ExitCode] =
val program =
for
jreClasspath <- javaHome.fold(JreClasspath.jrtPath())(JreClasspath.jrtPath)
workingDir = cwd.getOrElse(Path(System.getProperty("user.dir")))
result <- build.ProjectClasspathProvider.provide(workingDir, module, jreClasspath, noCache, config).use { (ctx, classpath) =>
result <- build.ProjectClasspathProvider.provide(workingDir, module, jreClasspath, noCache, config, testScope).use { (ctx, classpath) =>
body(ctx, classpath, jreClasspath)
}
yield result
Expand Down
5 changes: 3 additions & 2 deletions lib/src/cellar/handlers/ProjectListHandler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ object ProjectListHandler:
javaHome: Option[Path] = None,
noCache: Boolean = false,
cwd: Option[Path] = None,
config: Config = Config.global
config: Config = Config.global,
testScope: Boolean = false
)(using Console[IO]): IO[ExitCode] =
ProjectHandler.run(javaHome, cwd, module, noCache, config) { (ctx, _, _) =>
ProjectHandler.run(javaHome, cwd, module, noCache, config, testScope) { (ctx, _, _) =>
given Context = ctx
ListHandler.runCore(fqn, limit, coord = None)
}
5 changes: 3 additions & 2 deletions lib/src/cellar/handlers/ProjectSearchHandler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ object ProjectSearchHandler:
javaHome: Option[Path] = None,
noCache: Boolean = false,
cwd: Option[Path] = None,
config: Config = Config.global
config: Config = Config.global,
testScope: Boolean = false
)(using Console[IO]): IO[ExitCode] =
ProjectHandler.run(javaHome, cwd, module, noCache, config) { (ctx, classpath, jreClasspath) =>
ProjectHandler.run(javaHome, cwd, module, noCache, config, testScope) { (ctx, classpath, jreClasspath) =>
given Context = ctx
SearchHandler.runCore(query, limit, classpath, jreClasspath)
}
13 changes: 10 additions & 3 deletions lib/test/src/cellar/ProjectAwareIntegrationTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ class ProjectAwareIntegrationTest extends CatsEffectSuite:
// --- ScalaCliBuildTool tests ---

test("ScalaCliBuildTool: rejects --module"):
build.ScalaCliBuildTool(Path(".")).extractClasspath(Some("foo")).attempt.map { result =>
build.ScalaCliBuildTool(Path(".")).extractClasspath(Some("foo"), testScope = false).attempt.map { result =>
assert(result.isLeft)
assert(result.left.exists(_.getMessage.contains("--module is not supported")))
}
Expand All @@ -176,15 +176,22 @@ class ProjectAwareIntegrationTest extends CatsEffectSuite:

test("MillBuildTool: rejects missing --module"):
build.MillBuildTool(Path("."), Config.global.mill)
.extractClasspath(None).attempt.map { result =>
.extractClasspath(None, testScope = false).attempt.map { result =>
assert(result.isLeft)
assert(result.left.exists(_.getMessage.contains("--module is required for Mill")))
}

test("MillBuildTool: rejects --test scope"):
build.MillBuildTool(Path("."), Config.global.mill)
.extractClasspath(Some("foo"), testScope = true).attempt.map { result =>
assert(result.isLeft)
assert(result.left.exists(_.getMessage.contains("--test is not supported for Mill")))
}

// --- SbtBuildTool tests ---

test("SbtBuildTool: rejects missing --module"):
build.SbtBuildTool(Path("."), Config.global.sbt).extractClasspath(None)
build.SbtBuildTool(Path("."), Config.global.sbt).extractClasspath(None, testScope = false)
.attempt.map { result =>
assert(result.isLeft)
assert(result.left.exists(_.getMessage.contains("--module is required for sbt")))
Expand Down