diff --git a/build.sbt b/build.sbt index a7ca5e948..884e2a538 100644 --- a/build.sbt +++ b/build.sbt @@ -162,6 +162,7 @@ lazy val cli = project name := "coursier-cli", libraryDependencies ++= Seq( "com.github.alexarchambault" %% "case-app" % "1.0.0-SNAPSHOT", + "com.lihaoyi" %% "ammonite-terminal" % "0.5.0", "ch.qos.logback" % "logback-classic" % "1.1.3" ), resourceGenerators in Compile += packageBin.in(bootstrap).in(Compile).map { jar => diff --git a/cli/src/main/scala/coursier/cli/Coursier.scala b/cli/src/main/scala/coursier/cli/Coursier.scala index 6c5a755b3..190e87442 100644 --- a/cli/src/main/scala/coursier/cli/Coursier.scala +++ b/cli/src/main/scala/coursier/cli/Coursier.scala @@ -36,7 +36,7 @@ case class CommonOptions( @Recurse cacheOptions: CacheOptions ) { - val verbose0 = verbose.length + (if (quiet) 1 else 0) + val verbose0 = verbose.length - (if (quiet) 1 else 0) } object CacheOptions { @@ -70,7 +70,7 @@ case class Fetch( val files0 = helper.fetch(main = true, sources = false, javadoc = false) - Console.out.println( + println( files0 .map(_.toString) .mkString("\n") @@ -115,7 +115,7 @@ case class Launch( val mainClass = if (mainClasses.isEmpty) { - Console.err.println(s"No main class found. Specify one with -M or --main.") + Helper.errPrintln("No main class found. Specify one with -M or --main.") sys.exit(255) } else if (mainClasses.size == 1) { val (_, mainClass) = mainClasses.head @@ -135,8 +135,7 @@ case class Launch( } yield mainClass mainClassOpt.getOrElse { - println(mainClasses) - Console.err.println(s"Cannot find default main class. Specify one with -M or --main.") + Helper.errPrintln(s"Cannot find default main class. Specify one with -M or --main.") sys.exit(255) } } @@ -147,18 +146,20 @@ case class Launch( val cls = try cl.loadClass(mainClass0) catch { case e: ClassNotFoundException => - println(s"Error: class $mainClass0 not found") + Helper.errPrintln(s"Error: class $mainClass0 not found") sys.exit(255) } val method = try cls.getMethod("main", classOf[Array[String]]) catch { case e: NoSuchMethodError => - println(s"Error: method main not found in $mainClass0") + Helper.errPrintln(s"Error: method main not found in $mainClass0") sys.exit(255) } if (common.verbose0 >= 1) - println(s"Calling $mainClass0 ${extraArgs.mkString(" ")}") + Helper.errPrintln(s"Launching $mainClass0 ${extraArgs.mkString(" ")}") + else if (common.verbose0 == 0) + Helper.errPrintln(s"Launching") Thread.currentThread().setContextClassLoader(cl) method.invoke(null, extraArgs.toArray) diff --git a/cli/src/main/scala/coursier/cli/Helper.scala b/cli/src/main/scala/coursier/cli/Helper.scala index 3aca39d73..8f303cc25 100644 --- a/cli/src/main/scala/coursier/cli/Helper.scala +++ b/cli/src/main/scala/coursier/cli/Helper.scala @@ -1,7 +1,7 @@ package coursier package cli -import java.io.File +import java.io.{ OutputStreamWriter, File } import java.util.UUID import scalaz.{ \/-, -\/ } @@ -28,31 +28,6 @@ object Helper { def errPrintln(s: String) = Console.err.println(s) - def defaultLogger: Files.Logger = - new Files.Logger { - def foundLocally(url: String, f: File) = {} - def downloadingArtifact(url: String) = - errPrintln(s"Downloading $url") - def downloadedArtifact(url: String, success: Boolean) = {} - def downloadLength(url: String, length: Long) = {} - def downloadProgress(url: String, downloaded: Long) = {} - } - - def verboseLogger: Files.Logger = - new Files.Logger { - def foundLocally(url: String, f: File) = - errPrintln(s"Found $url locally (${fileRepr(f)})") - def downloadingArtifact(url: String) = - errPrintln(s"Downloading $url") - def downloadedArtifact(url: String, success: Boolean) = - errPrintln( - if (success) s"Downloaded $url" - else s"Failed: $url" - ) - def downloadLength(url: String, length: Long) = {} - def downloadProgress(url: String, downloaded: Long) = {} - } - def mainClasses(cl: ClassLoader): Map[(String, String), String] = { import scala.collection.JavaConverters._ @@ -79,15 +54,6 @@ class Helper( import common._ import Helper.errPrintln - - val logger = - if (verbose0 < 0) - None - else if (verbose0 == 0) - Some(Helper.defaultLogger) - else - Some(Helper.verboseLogger) - implicit val cachePolicy = if (offline) CachePolicy.LocalOnly @@ -198,9 +164,15 @@ class Helper( filter = Some(dep => keepOptional || !dep.optional) ) + val logger = + if (verbose0 >= 0) + Some(new TermDisplay(new OutputStreamWriter(System.err))) + else + None + logger.foreach(_.init()) val fetchQuiet = coursier.Fetch(repositories, files.fetch(logger = logger)) val fetch0 = - if (verbose0 == 0) fetchQuiet + if (verbose0 <= 0) fetchQuiet else { modVers: Seq[(Module, String)] => val print = Task{ @@ -218,6 +190,8 @@ class Helper( .run(fetch0, maxIterations) .run + logger.foreach(_.stop()) + if (!res.isDone) { errPrintln(s"Maximum number of iteration reached!") sys.exit(1) @@ -254,7 +228,7 @@ class Helper( .toList .sortBy(repr) - if (verbose0 >= 0) { + if (verbose0 >= 1) { println("") println( trDeps @@ -278,8 +252,8 @@ class Helper( } def fetch(main: Boolean, sources: Boolean, javadoc: Boolean): Seq[File] = { - println("") - + if (verbose0 >= 0) + errPrintln("Fetching artifacts") val artifacts0 = res.artifacts val main0 = main || (!sources && !javadoc) val artifacts = artifacts0.flatMap{ artifact => @@ -294,9 +268,15 @@ class Helper( l } + val logger = + if (verbose0 >= 0) + Some(new TermDisplay(new OutputStreamWriter(System.err))) + else + None + logger.foreach(_.init()) val tasks = artifacts.map(artifact => files.file(artifact, logger = logger).run.map(artifact.->)) - def printTask = Task{ - if (verbose0 >= 0 && artifacts.nonEmpty) + def printTask = Task { + if (verbose0 >= 1 && artifacts.nonEmpty) println(s"Found ${artifacts.length} artifacts") } val task = printTask.flatMap(_ => Task.gatherUnordered(tasks)) @@ -305,6 +285,8 @@ class Helper( val errors = results.collect{case (artifact, -\/(err)) => artifact -> err } val files0 = results.collect{case (artifact, \/-(f)) => f } + logger.foreach(_.stop()) + if (errors.nonEmpty) { println(s"${errors.size} error(s):") for ((artifact, error) <- errors) { diff --git a/cli/src/main/scala/coursier/cli/TermDisplay.scala b/cli/src/main/scala/coursier/cli/TermDisplay.scala new file mode 100644 index 000000000..acdcbaf52 --- /dev/null +++ b/cli/src/main/scala/coursier/cli/TermDisplay.scala @@ -0,0 +1,146 @@ +package coursier.cli + +import java.io.Writer +import java.util.concurrent._ + +import ammonite.terminal.{ TTY, Ansi } + +import coursier.Files.Logger + +import scala.annotation.tailrec +import scala.collection.mutable.ArrayBuffer + +class TermDisplay(out: Writer) extends Logger { + + private val ansi = new Ansi(out) + private var width = 80 + private val refreshInterval = 1000 / 60 + private val lock = new AnyRef + private val t = new Thread("TermDisplay") { + override def run() = lock.synchronized { + val baseExtraWidth = width / 5 + @tailrec def helper(lineCount: Int): Unit = + Option(q.poll(100L, TimeUnit.MILLISECONDS)) match { + case None => helper(lineCount) + case Some(Left(())) => // poison pill + case Some(Right(())) => + // update display + + for (_ <- 0 until lineCount) { + ansi.up(1) + ansi.clearLine(2) + } + + val downloads0 = downloads.synchronized { + downloads + .toVector + .map { url => url -> infos.get(url) } + .sortBy { case (_, info) => - info.pct.sum } + } + + for ((url, info) <- downloads0) { + assert(info != null, s"Incoherent state ($url)") + val pctOpt = info.pct.map(100.0 * _) + val extra = s"(${pctOpt.map(pct => f"$pct%.2f %%, ").mkString}${info.downloaded}${info.length.map(" / " + _).mkString})" + + val total = url.length + 1 + extra.length + val (url0, extra0) = + if (total >= width) { // or > ? If equal, does it go down 2 lines? + val overflow = total - width + 1 + + val extra0 = + if (extra.length > baseExtraWidth) + extra.take((baseExtraWidth max (extra.length - overflow)) - 1) + "…" + else + extra + + val total0 = url.length + 1 + extra0.length + val overflow0 = total0 - width + 1 + + val url0 = + if (total0 >= width) + url.take(((width - baseExtraWidth - 1) max (url.length - overflow0)) - 1) + "…" + else + url + + (url0, extra0) + } else + (url, extra) + + out.write(s"$url0 $extra0\n") + } + + out.flush() + Thread.sleep(refreshInterval) + helper(downloads0.length) + } + + helper(0) + } + } + + t.setDaemon(true) + + def init(): Unit = { + width = TTY.consoleDim("cols") + ansi.clearLine(2) + t.start() + } + + def stop(): Unit = { + q.put(Left(())) + lock.synchronized(()) + } + + private case class Info(downloaded: Long, length: Option[Long]) { + def pct: Option[Double] = length.map(downloaded.toDouble / _) + } + + private val downloads = new ArrayBuffer[String] + private val infos = new ConcurrentHashMap[String, Info] + + private val q = new LinkedBlockingDeque[Either[Unit, Unit]] + def update(): Unit = { + if (q.size() == 0) + q.put(Right(())) + } + + override def downloadingArtifact(url: String): Unit = { + assert(!infos.containsKey(url)) + val prev = infos.putIfAbsent(url, Info(0L, None)) + assert(prev == null) + + downloads.synchronized { + downloads.append(url) + } + + update() + } + override def downloadLength(url: String, length: Long): Unit = { + val info = infos.get(url) + assert(info != null) + val newInfo = info.copy(length = Some(length)) + infos.put(url, newInfo) + + update() + } + override def downloadProgress(url: String, downloaded: Long): Unit = { + val info = infos.get(url) + assert(info != null) + val newInfo = info.copy(downloaded = downloaded) + infos.put(url, newInfo) + + update() + } + override def downloadedArtifact(url: String, success: Boolean): Unit = { + downloads.synchronized { + downloads -= url + } + + val info = infos.remove(url) + assert(info != null) + + update() + } + +} diff --git a/files/src/main/scala/coursier/Files.scala b/files/src/main/scala/coursier/Files.scala index 4b08e3f51..e0d074035 100644 --- a/files/src/main/scala/coursier/Files.scala +++ b/files/src/main/scala/coursier/Files.scala @@ -269,11 +269,11 @@ object Files { val defaultConcurrentDownloadCount = 6 trait Logger { - def foundLocally(url: String, f: File): Unit - def downloadingArtifact(url: String): Unit - def downloadLength(url: String, length: Long): Unit - def downloadProgress(url: String, downloaded: Long): Unit - def downloadedArtifact(url: String, success: Boolean): Unit + def foundLocally(url: String, f: File): Unit = {} + def downloadingArtifact(url: String): Unit = {} + def downloadLength(url: String, length: Long): Unit = {} + def downloadProgress(url: String, downloaded: Long): Unit = {} + def downloadedArtifact(url: String, success: Boolean): Unit = {} } var bufferSize = 1024*1024