From e3375b18c190c20cbdc9b1c062ea8d748d715914 Mon Sep 17 00:00:00 2001 From: rochala Date: Wed, 27 May 2026 20:14:06 +0200 Subject: [PATCH] Add --test flag to query test-scope classpath (#84) Project-aware commands (get/list/search) only ever resolved the compile-scope classpath, so symbols from test-only dependencies were never found. Add a `--test` flag that selects the test scope: - sbt: exports `/Test/fullClasspath` - scala-cli: compiles with `--test` - Mill: rejected with guidance (test code is a separate module, e.g. `--module foo.test`), since Mill has no Compile/Test scope axis The classpath cache key includes the scope so compile/test results don't collide. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 8 ++++++++ cli/src/cellar/cli/CellarApp.scala | 20 +++++++++++-------- lib/src/cellar/CellarError.scala | 5 +++++ lib/src/cellar/build/BuildTool.scala | 8 ++++++-- lib/src/cellar/build/MillBuildTool.scala | 8 ++++---- .../build/ProjectClasspathProvider.scala | 19 +++++++++--------- lib/src/cellar/build/SbtBuildTool.scala | 10 ++++++---- lib/src/cellar/build/ScalaCliBuildTool.scala | 11 ++++++---- .../cellar/handlers/ProjectGetHandler.scala | 5 +++-- lib/src/cellar/handlers/ProjectHandler.scala | 5 +++-- .../cellar/handlers/ProjectListHandler.scala | 5 +++-- .../handlers/ProjectSearchHandler.scala | 5 +++-- .../cellar/ProjectAwareIntegrationTest.scala | 13 +++++++++--- 13 files changed, 80 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 18e8b48..e908cb4 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,12 @@ cellar search [--module ] - **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 `/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 @@ -149,6 +155,7 @@ cellar get-external org.typelevel:cats-core_3:latest cats.Monad |---|---|---| | `--module `, `-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 ` | all | Use a specific JDK for JRE classpath | | `-r`, `--repository ` | external commands | Extra Maven repository URL (repeatable) | | `-l`, `--limit ` | `list`, `list-external`, `search`, `search-external` | Max results (default: 50) | @@ -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 diff --git a/cli/src/cellar/cli/CellarApp.scala b/cli/src/cellar/cli/CellarApp.scala index 16fc1ac..619326c 100644 --- a/cli/src/cellar/cli/CellarApp.scala +++ b/cli/src/cellar/cli/CellarApp.scala @@ -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 @@ -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) } } diff --git a/lib/src/cellar/CellarError.scala b/lib/src/cellar/CellarError.scala index ba215c3..d8efbc9 100644 --- a/lib/src/cellar/CellarError.scala +++ b/lib/src/cellar/CellarError.scala @@ -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" diff --git a/lib/src/cellar/build/BuildTool.scala b/lib/src/cellar/build/BuildTool.scala index dfb5d5c..e66bbba 100644 --- a/lib/src/cellar/build/BuildTool.scala +++ b/lib/src/cellar/build/BuildTool.scala @@ -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) diff --git a/lib/src/cellar/build/MillBuildTool.scala b/lib/src/cellar/build/MillBuildTool.scala index b0f8944..1f838f1 100644 --- a/lib/src/cellar/build/MillBuildTool.scala +++ b/lib/src/cellar/build/MillBuildTool.scala @@ -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) diff --git a/lib/src/cellar/build/ProjectClasspathProvider.scala b/lib/src/cellar/build/ProjectClasspathProvider.scala index 71b60fd..bdae227 100644 --- a/lib/src/cellar/build/ProjectClasspathProvider.scala +++ b/lib/src/cellar/build/ProjectClasspathProvider.scala @@ -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 diff --git a/lib/src/cellar/build/SbtBuildTool.scala b/lib/src/cellar/build/SbtBuildTool.scala index 36e2df4..4fbeca3 100644 --- a/lib/src/cellar/build/SbtBuildTool.scala +++ b/lib/src/cellar/build/SbtBuildTool.scala @@ -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 diff --git a/lib/src/cellar/build/ScalaCliBuildTool.scala b/lib/src/cellar/build/ScalaCliBuildTool.scala index de24fdb..209a1a3 100644 --- a/lib/src/cellar/build/ScalaCliBuildTool.scala +++ b/lib/src/cellar/build/ScalaCliBuildTool.scala @@ -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 @@ -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)) diff --git a/lib/src/cellar/handlers/ProjectGetHandler.scala b/lib/src/cellar/handlers/ProjectGetHandler.scala index f5d6b0c..8153bcd 100644 --- a/lib/src/cellar/handlers/ProjectGetHandler.scala +++ b/lib/src/cellar/handlers/ProjectGetHandler.scala @@ -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) } diff --git a/lib/src/cellar/handlers/ProjectHandler.scala b/lib/src/cellar/handlers/ProjectHandler.scala index 2683156..e62d6c0 100644 --- a/lib/src/cellar/handlers/ProjectHandler.scala +++ b/lib/src/cellar/handlers/ProjectHandler.scala @@ -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 diff --git a/lib/src/cellar/handlers/ProjectListHandler.scala b/lib/src/cellar/handlers/ProjectListHandler.scala index 2b03c27..8981a65 100644 --- a/lib/src/cellar/handlers/ProjectListHandler.scala +++ b/lib/src/cellar/handlers/ProjectListHandler.scala @@ -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) } diff --git a/lib/src/cellar/handlers/ProjectSearchHandler.scala b/lib/src/cellar/handlers/ProjectSearchHandler.scala index 2b255ee..9a05c51 100644 --- a/lib/src/cellar/handlers/ProjectSearchHandler.scala +++ b/lib/src/cellar/handlers/ProjectSearchHandler.scala @@ -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) } diff --git a/lib/test/src/cellar/ProjectAwareIntegrationTest.scala b/lib/test/src/cellar/ProjectAwareIntegrationTest.scala index 0373c97..0340d4f 100644 --- a/lib/test/src/cellar/ProjectAwareIntegrationTest.scala +++ b/lib/test/src/cellar/ProjectAwareIntegrationTest.scala @@ -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"))) } @@ -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")))