Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion build.mill
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ trait CommonScalaModule extends ScalaModule with ScalafixModule {
object lib extends CommonScalaModule {
def mvnDeps = Seq(
mvn"org.typelevel::cats-effect:3.5.7",
mvn"co.fs2::fs2-core:3.11.0",
mvn"co.fs2::fs2-io:3.11.0",
mvn"io.get-coursier:interface:1.0.28",
mvn"ch.epfl.scala::tasty-query:1.7.0",
mvn"org.scala-lang:scala3-tasty-inspector_3:3.8.1"
Expand Down
4 changes: 3 additions & 1 deletion cli/src/cellar/cli/CellarApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import cellar.handlers.{DepsHandler, GetHandler, GetSourceHandler, ListHandler,
import com.monovore.decline.*
import com.monovore.decline.effect.*
import coursierapi.{MavenRepository, Repository}
import java.nio.file.Path
import fs2.io.file.Path

object CellarApp
extends CommandIOApp(
Expand All @@ -23,6 +23,8 @@ object CellarApp
searchSubcmd orElse searchExternalSubcmd orElse
depsSubcmd

private given Argument[Path] = Argument[java.nio.file.Path].map(Path.fromNioPath)

private val coordArg: Opts[String] =
Opts.argument[String]("coordinate")

Expand Down
2 changes: 1 addition & 1 deletion lib/src/cellar/CellarError.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package cellar

import cellar.build.BuildToolKind
import java.nio.file.Path
import fs2.io.file.Path

sealed trait CellarError extends Throwable

Expand Down
6 changes: 3 additions & 3 deletions lib/src/cellar/ContextResource.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ package cellar
import cats.effect.{IO, Resource}
import cats.syntax.monadError.*
import coursierapi.Repository
import fs2.io.file.Path
import tastyquery.Classpaths.Classpath
import tastyquery.Contexts.Context
import tastyquery.jdk.ClasspathLoaders
import java.nio.file.Path

object ContextResource:
def make(jars: Seq[Path], jreClasspath: Classpath): Resource[IO, (Context, Classpath)] =
Expand All @@ -28,11 +28,11 @@ object ContextResource:
* (e.g. vendor-injected JRT modules such as the Azul CRS client).
*/
private def readClasspathRobust(paths: List[Path]): Classpath =
try ClasspathLoaders.read(paths)
try ClasspathLoaders.read(paths.map(_.toNioPath))
catch
case e: MatchError =>
val bad = paths.find { p =>
try { ClasspathLoaders.read(List(p)); false }
try { ClasspathLoaders.read(List(p.toNioPath)); false }
catch case _: MatchError => true
}
bad match
Expand Down
7 changes: 4 additions & 3 deletions lib/src/cellar/CoursierFetchClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ package cellar

import cats.effect.IO
import coursierapi.{Cache, Fetch, Repository}
import java.nio.file.Path
import fs2.io.file.Path

import scala.jdk.CollectionConverters.*

object CoursierFetchClient:
Expand All @@ -18,7 +19,7 @@ object CoursierFetchClient:
.addClassifiers("sources")
.withMainArtifacts(false)
if extraRepositories.nonEmpty then fetch.addRepositories(extraRepositories*)
fetch.fetch().asScala.headOption.map(_.toPath)
fetch.fetch().asScala.headOption.map(file => Path.fromNioPath(file.toPath))
}.handleError(_ => None)

def fetchClasspath(
Expand All @@ -29,7 +30,7 @@ object CoursierFetchClient:
val dep = coord.toCoursierDependency
val fetch = Fetch.create().addDependencies(dep).withCache(Cache.create())
if extraRepositories.nonEmpty then fetch.addRepositories(extraRepositories*)
fetch.fetch().asScala.toSeq.map(_.toPath)
fetch.fetch().asScala.toSeq.map(file => Path.fromNioPath(file.toPath))
}.handleErrorWith { case e: coursierapi.error.CoursierError =>
CoordinateCompleter.suggest(coord, extraRepositories).flatMap { suggestions =>
IO.raiseError(CellarError.CoordinateNotFound(coord, e, suggestions))
Expand Down
8 changes: 4 additions & 4 deletions lib/src/cellar/JreClasspath.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,20 @@ object JreClasspath:
if isNativeImage then loadBundledJre()
else
sys.env.get("JAVA_HOME") match
case Some(h) => jrtPath(Path.of(h))
case Some(h) => jrtPath(fs2.io.file.Path(h))
case None =>
IO.raiseError(new RuntimeException(
"Could not locate JRE classpath. Set JAVA_HOME or pass --java-home pointing to a JDK installation."
))

def jrtPath(javaHome: Path): IO[Classpaths.Classpath] =
def jrtPath(javaHome: fs2.io.file.Path): IO[Classpaths.Classpath] =
// JRT filesystem is not reliably accessible in GraalVM native image:
// https://github.com/oracle/graal/issues/10013
if isNativeImage then loadBundledJre()
else
IO.blocking {
val jrtFsJar = javaHome.resolve("lib/jrt-fs.jar")
if !Files.exists(jrtFsJar) then
if !Files.exists(jrtFsJar.toNioPath) then
throw new IllegalArgumentException(
s"Not a valid JDK home (missing lib/jrt-fs.jar): $javaHome"
)
Expand All @@ -47,7 +47,7 @@ object JreClasspath:
provider.newFileSystem(URI.create("jrt:/"), env)
catch case _: java.lang.reflect.InaccessibleObjectException =>
// JVM without --add-opens: fall back to URLClassLoader
val cl = new URLClassLoader(Array(jrtFsJar.toUri.toURL))
val cl = new URLClassLoader(Array(jrtFsJar.toNioPath.toUri.toURL))
FileSystems.newFileSystem(URI.create("jrt:/"), env, cl)
ClasspathLoaders.read(Files.list(fs.getPath("modules")).iterator().asScala.toList)
}
Expand Down
5 changes: 3 additions & 2 deletions lib/src/cellar/SourceFetcher.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ package cellar

import cats.effect.IO
import coursierapi.Repository
import java.nio.file.Path
import fs2.io.file.Path

import java.util.zip.ZipFile
import scala.jdk.CollectionConverters.*

Expand Down Expand Up @@ -30,7 +31,7 @@ object SourceFetcher:
endLine: Int
): Either[String, SourceResult] =
val normalizedSource = sourceFilePath.replace('\\', '/')
val zip = ZipFile(jar.toFile)
val zip = ZipFile(jar.toNioPath.toFile)
try
val entry = zip.entries().asScala.find { e =>
!e.isDirectory && normalizedSource.endsWith(e.getName)
Expand Down
27 changes: 16 additions & 11 deletions lib/src/cellar/build/BuildFingerprint.scala
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
package cellar.build

import cats.effect.IO
import java.nio.file.{Files, Path}
import java.security.MessageDigest
import cats.syntax.all.*
import fs2.Chunk
import fs2.io.file.{Files, Path}
import fs2.hashing.*

import java.nio.charset.StandardCharsets

object BuildFingerprint:
def compute(files: List[Path], module: String): IO[String] =
IO.blocking {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(module.getBytes(java.nio.charset.StandardCharsets.UTF_8))
files.sorted.foreach { path =>
if Files.exists(path) then
digest.update(path.toString.getBytes(java.nio.charset.StandardCharsets.UTF_8))
digest.update(Files.readAllBytes(path))
}
digest.digest().map(b => f"$b%02x").mkString
Hashing[IO].hasher(HashAlgorithm.SHA256).use { hasher =>
for
_ <- hasher.update(Chunk.array(module.getBytes(StandardCharsets.UTF_8)))
_ <- fs2.Stream.emits(files.sortBy(_.toNioPath))
.evalFilter(Files[IO].exists)
.evalTap(path => hasher.update(Chunk.array(path.toString.getBytes(StandardCharsets.UTF_8))))
.flatMap(path => Files[IO].readAll(path).through(hasher.update))
.compile.drain
hash <- hasher.hash
yield hash.bytes.foldMap(b => f"$b%02x")
}
4 changes: 2 additions & 2 deletions lib/src/cellar/build/BuildTool.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ package cellar.build

import cats.effect.IO
import cellar.CellarError
import java.nio.file.Path
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 fingerprintFiles(): IO[List[Path]]
def fingerprintFiles: IO[List[Path]]

protected def requireModule(module: Option[String]): IO[String] =
module match
Expand Down
18 changes: 9 additions & 9 deletions lib/src/cellar/build/BuildToolDetector.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package cellar.build

import cats.effect.IO
import java.nio.file.{Files, Path}
import cats.syntax.all.*
import fs2.io.file.{Files, Path}

enum BuildToolKind:
case Mill, Sbt, ScalaCli
Expand All @@ -11,14 +12,13 @@ object BuildToolDetector:

/** Detect the build tool kind from marker files only (no binary check). */
def detectKind(dir: Path): IO[BuildToolKind] =
IO.blocking {
val millMarker = millMarkers.find(m => Files.exists(dir.resolve(m)))
val hasSbt = Files.exists(dir.resolve("build.sbt"))
val hasScalaBuild = Files.isDirectory(dir.resolve(".scala-build"))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets restore .scala-build detection. It is duplicated for now with the fallback but in the future if we want to add support for any other build tool, the fallback may no longer apply

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-added and refactored the code using findM for better readability.

{
val millMarker = millMarkers.findM(m => Files[IO].exists(dir.resolve(m)))
val hasSbt = Files[IO].exists(dir.resolve("build.sbt"))

if millMarker.isDefined then BuildToolKind.Mill
else if hasSbt then BuildToolKind.Sbt
else if hasScalaBuild then BuildToolKind.ScalaCli
else BuildToolKind.ScalaCli // fallback
millMarker.map(_.isDefined).ifM(
IO.pure(BuildToolKind.Mill),
hasSbt.ifF(BuildToolKind.Sbt, BuildToolKind.ScalaCli) /* scala-cli is also fallback */
)
}

38 changes: 18 additions & 20 deletions lib/src/cellar/build/ClasspathCache.scala
Original file line number Diff line number Diff line change
@@ -1,30 +1,28 @@
package cellar.build

import cats.effect.IO
import java.nio.file.{Files, Path, StandardCopyOption}
import cats.syntax.all.*
import fs2.Stream
import fs2.io.file.{CopyFlag, CopyFlags, Files, Path}

class ClasspathCache(projectDir: Path):
private val cacheDir = projectDir.resolve(".cellar").resolve("cache")

def get(hash: String): IO[Option[List[Path]]] =
IO.blocking {
val file = cacheDir.resolve(s"$hash.txt")
if !Files.exists(file) then None
else
val paths = Files.readString(file).linesIterator
.filter(_.nonEmpty)
.map(Path.of(_))
.toList
// Validate all paths still exist
if paths.forall(Files.exists(_)) then Some(paths)
else None
val file = cacheDir.resolve(s"$hash.txt")
Files[IO].exists(file).flatMap {
case true =>
for
paths <- Files[IO].readUtf8Lines(file).filter(_.nonEmpty).map(Path(_)).compile.toList
allExist <- paths.forallM(Files[IO].exists)
yield Option.when(allExist)(paths)
case false => IO.pure(None)
}

def put(hash: String, paths: List[Path]): IO[Unit] =
IO.blocking {
Files.createDirectories(cacheDir)
val file = cacheDir.resolve(s"$hash.txt")
val tmp = cacheDir.resolve(s"$hash.tmp")
Files.writeString(tmp, paths.map(_.toString).mkString("\n") + "\n")
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
}
def put(hash: String, paths: List[Path]): IO[Unit] = for {
_ <- Files[IO].createDirectories(cacheDir)
file = cacheDir.resolve(s"$hash.txt")
tmp = cacheDir.resolve(s"$hash.tmp")
_ <- Stream(paths.map(_.toString).mkString("\n") + "\n").through(Files[IO].writeUtf8(tmp)).compile.drain
_ <- Files[IO].move(tmp, file, CopyFlags(CopyFlag.ReplaceExisting, CopyFlag.AtomicMove))
} yield ()
22 changes: 13 additions & 9 deletions lib/src/cellar/build/ClasspathOutputParser.scala
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
package cellar.build

import java.nio.file.{Files, Path}
import cats.effect.IO
import cats.syntax.all._
import fs2.io.file.{Files, Path}

object ClasspathOutputParser:

/** Parse Mill's JSON array output: `["ref:hash:/path", "/path2"]` */
def parseJsonArray(input: String, checkExists: Boolean = true): Either[String, List[Path]] =
def parseJsonArray(input: String, checkExists: Boolean = true): IO[Either[String, List[Path]]] =
val trimmed = input.trim
if !trimmed.startsWith("[") || !trimmed.endsWith("]") then
return Left(s"Expected JSON array but got: ${trimmed.take(100)}")
return IO.pure(Left(s"Expected JSON array but got: ${trimmed.take(100)}"))

val inner = trimmed.substring(1, trimmed.length - 1).trim
if inner.isEmpty then return Left("Build tool produced an empty classpath.")
if inner.isEmpty then return IO.pure(Left("Build tool produced an empty classpath."))

val entries = inner.split(",").map(_.trim.stripPrefix("\"").stripSuffix("\"")).toList
val paths = entries.map { entry =>
Expand All @@ -20,12 +22,14 @@ object ClasspathOutputParser:
val path = entry.lastIndexOf(":/") match
case -1 => entry // plain path
case idx => entry.substring(idx + 1) // strip prefix up to the path
Path.of(path)
Path(path)
}

val filtered = if checkExists then paths.filter(p => Files.exists(p)) else paths
if filtered.isEmpty then Left("Build tool produced an empty classpath (all paths filtered).")
else Right(filtered)
val filtered = if checkExists then paths.filterA(p => Files[IO].exists(p)) else IO.pure(paths)
filtered.map {
case Nil => Left("Build tool produced an empty classpath (all paths filtered).")
case filtered => Right(filtered)
}

/** Parse colon-separated classpath output (sbt, scala-cli) */
def parseColonSeparated(input: String): Either[String, List[Path]] =
Expand All @@ -36,4 +40,4 @@ object ClasspathOutputParser:
.toList

if entries.isEmpty then Left("Build tool produced an empty classpath.")
else Right(entries.map(Path.of(_)))
else Right(entries.map(Path(_)))
Loading
Loading