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
7 changes: 7 additions & 0 deletions modules/cli/src/main/scala/scala/cli/commands/WatchUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ object WatchUtil {
s"$gray$message$reset"
}

def clearScreen(): Unit = {
// \u001b[2J clears the entire screen
// \u001b[H moves the cursor to the top-left corner (home position)
System.out.print("\u001b[2J\u001b[H")
System.out.flush()
}

def printWatchMessage(): Unit =
System.err.println(waitMessage("Watching sources"))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import caseapp.*
import caseapp.core.help.HelpFormat

import java.io.File
import java.util.concurrent.atomic.AtomicBoolean

import scala.build.options.Scope
import scala.build.{Build, BuildThreads, Builds, Logger}
Expand Down Expand Up @@ -103,7 +104,8 @@ object Compile extends ScalaCommand[CompileOptions] with BuildCommandHelpers {

val shouldBuildTestScope = options.shared.scope.test.getOrElse(false)
if (options.watch.watchMode) {
val watcher = Build.watch(
val isFirstRun = new AtomicBoolean(true)
val watcher = Build.watch(
inputs,
buildOptions,
compilerMaker,
Expand All @@ -115,6 +117,8 @@ object Compile extends ScalaCommand[CompileOptions] with BuildCommandHelpers {
actionableDiagnostics = actionableDiagnostics,
postAction = () => WatchUtil.printWatchMessage()
) { res =>
if (options.watch.watchClearScreen && !isFirstRun.getAndSet(false))
WatchUtil.clearScreen()
for (builds <- res.orReport(logger))
postBuild(builds, allowExit = false)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import packager.windows.WindowsPackage

import java.io.{ByteArrayOutputStream, OutputStream}
import java.nio.file.attribute.FileTime
import java.util.concurrent.atomic.AtomicBoolean
import java.util.zip.{ZipEntry, ZipOutputStream}

import scala.build.*
Expand Down Expand Up @@ -89,6 +90,7 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers {
val withTestScope = options.shared.scope.test.getOrElse(false)
if options.watch.watchMode then {
var expectedModifyEpochSecondOpt = Option.empty[Long]
val isFirstRun = new AtomicBoolean(true)
val watcher = Build.watch(
inputs,
initialBuildOptions,
Expand All @@ -101,6 +103,8 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers {
actionableDiagnostics = actionableDiagnostics,
postAction = () => WatchUtil.printWatchMessage()
) { res =>
if (options.watch.watchClearScreen && !isFirstRun.getAndSet(false))
WatchUtil.clearScreen()
res.orReport(logger).map(_.builds).foreach {
case b if b.forall(_.success) =>
val successfulBuilds = b.collect { case s: Build.Successful => s }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import java.nio.charset.StandardCharsets
import java.nio.file.Paths
import java.time.{Instant, LocalDateTime, ZoneOffset}
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean

import scala.build.*
import scala.build.EitherCps.{either, value}
Expand Down Expand Up @@ -255,7 +256,8 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers {
publishLocal = false,
forceSigningExternally = options.signingCli.forceSigningExternally.getOrElse(false),
parallelUpload = options.parallelUpload,
options.watch.watch,
watch = options.watch.watch,
watchClearScreen = options.watch.watchClearScreen,
isCi = options.publishParams.isCi,
() => configDb,
options.mainClass,
Expand All @@ -279,6 +281,7 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers {
forceSigningExternally: Boolean,
parallelUpload: Option[Boolean],
watch: Boolean,
watchClearScreen: Boolean,
isCi: Boolean,
configDb: () => ConfigDb,
mainClassOptions: MainClassOptions,
Expand All @@ -288,7 +291,8 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers {
val actionableDiagnostics = configDb().get(Keys.actions).getOrElse(None)

if watch then {
val watcher = Build.watch(
val isFirstRun = new AtomicBoolean(true)
val watcher = Build.watch(
inputs = inputs,
options = initialBuildOptions,
compilerMaker = compilerMaker,
Expand All @@ -299,8 +303,10 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers {
partial = None,
actionableDiagnostics = actionableDiagnostics,
postAction = () => WatchUtil.printWatchMessage()
) {
_.orReport(logger).foreach { builds =>
) { res =>
if (watchClearScreen && !isFirstRun.getAndSet(false))
WatchUtil.clearScreen()
res.orReport(logger).foreach { builds =>
maybePublish(
builds = builds,
workingDir = workingDir,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ object PublishLocal extends ScalaCommand[PublishLocalOptions] {
forceSigningExternally = options.scalaSigning.forceSigningExternally.getOrElse(false),
parallelUpload = Some(true),
watch = options.watch.watch,
watchClearScreen = options.watch.watchClearScreen,
isCi = options.publishParams.isCi,
configDb = () => ConfigDb.empty, // shouldn't be used, no need of repo credentials here
mainClassOptions = options.mainClass,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import coursier.error.ResolutionError
import dependency.*

import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.zip.ZipFile

import scala.build.*
Expand Down Expand Up @@ -208,7 +209,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers {
}
}
else if (options.sharedRepl.watch.watchMode) {
val watcher = Build.watch(
val isFirstRun = new AtomicBoolean(true)
val watcher = Build.watch(
inputs,
initialBuildOptions,
compilerMaker,
Expand All @@ -220,6 +222,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers {
actionableDiagnostics = actionableDiagnostics,
postAction = () => WatchUtil.printWatchMessage()
) { res =>
if (options.sharedRepl.watch.watchClearScreen && !isFirstRun.getAndSet(false))
WatchUtil.clearScreen()
for (builds <- res.orReport(logger))
postBuild(builds, allowExit = false) {
successfulBuilds =>
Expand Down
7 changes: 5 additions & 2 deletions modules/cli/src/main/scala/scala/cli/commands/run/Run.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import caseapp.core.help.HelpFormat
import java.io.File
import java.util.Locale
import java.util.concurrent.CompletableFuture
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference}

import scala.build.*
import scala.build.EitherCps.{either, value}
Expand Down Expand Up @@ -252,7 +252,8 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
*/
val mainThreadOpt = AtomicReference(Option.empty[Thread])

val watcher = Build.watch(
val isFirstRun = new AtomicBoolean(true)
val watcher = Build.watch(
inputs = inputs,
options = initialBuildOptions,
compilerMaker = compilerMaker,
Expand All @@ -266,6 +267,8 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
if processOpt.get().exists(_._1.isAlive()) then WatchUtil.printWatchWhileRunningMessage()
else WatchUtil.printWatchMessage()
) { res =>
if (options.sharedRun.watch.watchClearScreen && !isFirstRun.getAndSet(false))
WatchUtil.clearScreen()
for ((process, onExitProcess) <- processOpt.get()) {
onExitProcess.cancel(true)
ProcUtil.interruptProcess(process, logger)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ final case class SharedWatchOptions(
@Tag(tags.should)
@Tag(tags.inShortHelp)
@Name("revolver")
restart: Boolean = false
restart: Boolean = false,
@Group(HelpGroup.Watch.toString)
@HelpMessage("Clear the screen each time watch mode detects changes and re-compiles or re-runs")
@Tag(tags.implementation)
@Name("watchCls")
@Name("watchClear")
watchClearScreen: Boolean = false
) { // format: on

lazy val watchMode: Boolean = watch || restart
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import caseapp.*
import caseapp.core.help.HelpFormat

import java.nio.file.Path
import java.util.concurrent.atomic.AtomicBoolean

import scala.build.*
import scala.build.EitherCps.{either, value}
Expand Down Expand Up @@ -146,7 +147,8 @@ object Test extends ScalaCommand[TestOptions] {
}

if (options.watch.watchMode) {
val watcher = Build.watch(
val isFirstRun = new AtomicBoolean(true)
val watcher = Build.watch(
inputs,
initialBuildOptions,
compilerMaker,
Expand All @@ -158,6 +160,8 @@ object Test extends ScalaCommand[TestOptions] {
actionableDiagnostics = actionableDiagnostics,
postAction = () => WatchUtil.printWatchMessage()
) { res =>
if (options.watch.watchClearScreen && !isFirstRun.getAndSet(false))
WatchUtil.clearScreen()
for (builds <- res.orReport(logger))
maybeTest(builds, allowExit = false)
}
Expand Down
55 changes: 55 additions & 0 deletions modules/cli/src/test/scala/cli/tests/WatchUtilTests.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package cli.tests

import com.eed3si9n.expecty.Expecty.expect
import munit.FunSuite
import scala.cli.commands.WatchUtil
import java.io.ByteArrayOutputStream
import java.io.PrintStream

class WatchUtilTests extends FunSuite {

test("clearScreen prints correct ANSI escape codes") {
val out = new ByteArrayOutputStream()
val ps = new PrintStream(out)
val oldOut = System.out
try {
System.setOut(ps)
WatchUtil.clearScreen()
ps.flush()
val output = out.toString()
expect(output == "\u001b[2J\u001b[H")
}
finally
System.setOut(oldOut)
}

test("printWatchMessage prints to stderr") {
val err = new ByteArrayOutputStream()
val ps = new PrintStream(err)
val oldErr = System.err
try {
System.setErr(ps)
WatchUtil.printWatchMessage()
ps.flush()
val output = err.toString()
expect(output.contains("Watching sources"))
}
finally
System.setErr(oldErr)
}

test("printWatchWhileRunningMessage prints to stderr") {
val err = new ByteArrayOutputStream()
val ps = new PrintStream(err)
val oldErr = System.err
try {
System.setErr(ps)
WatchUtil.printWatchWhileRunningMessage()
ps.flush()
val output = err.toString()
expect(output.contains("Watching sources"))
}
finally
System.setErr(oldErr)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.eed3si9n.expecty.Expecty.expect
import java.io.File

import scala.cli.integration.util.BloopUtil
import scala.concurrent.duration.DurationInt
import scala.util.Properties

abstract class CompileTestDefinitions
Expand Down Expand Up @@ -903,4 +904,40 @@ abstract class CompileTestDefinitions
)
}
}

// TODO make this pass reliably on Mac CI
if (!Properties.isMac || !TestUtil.isCI)
test("compile --watch with --watch-clear-screen clears screen on recompile") {
val inputPath = os.rel / "example.scala"

def code(hasError: Boolean) =
if (hasError) """object Example { val x: String = 1 }""" // compile error
else """object Example { val x: Int = 1 }""" // compiles fine

TestInputs(inputPath -> code(hasError = false)).fromRoot { root =>
TestUtil.withProcessWatching(
proc = os.proc(
TestUtil.cli,
"compile",
inputPath.toString(),
"--watch",
"--watch-clear-screen",
extraOptions
)
.spawn(cwd = root, mergeErrIntoOut = true),
timeout = 120.seconds
) { (proc, timeout, ec) =>
var line = TestUtil.readLine(proc.stdout, ec, timeout)
while (!line.contains("Watching sources"))
line = TestUtil.readLine(proc.stdout, ec, timeout)
os.write.over(root / inputPath, code(hasError = true))
line = TestUtil.readLine(proc.stdout, ec, timeout)
while (!line.contains("error") && !line.contains("\u001b[2J"))
line = TestUtil.readLine(proc.stdout, ec, timeout)
while (!line.toLowerCase.contains("error"))
line = TestUtil.readLine(proc.stdout, ec, timeout)
expect(line.toLowerCase.contains("error"))
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import java.util
import java.util.zip.ZipFile

import scala.cli.integration.TestUtil.*
import scala.concurrent.duration.DurationInt
import scala.jdk.CollectionConverters.*
import scala.util.{Properties, Using}

Expand Down Expand Up @@ -1560,4 +1561,38 @@ abstract class PackageTestDefinitions extends ScalaCliSuite with TestScalaVersio
expect(res.out.trim().contains(s"$moduleName.js"))
}
}

// TODO make this pass reliably on Mac CI
if (!Properties.isMac || !TestUtil.isCI)
test("package --watch with --watch-clear-screen clears screen on repackage") {
val inputPath = os.rel / "example.scala"

def code(message: String) =
s"""object Example extends App { println("$message") }"""

TestInputs(inputPath -> code("Hello1")).fromRoot { root =>
TestUtil.withProcessWatching(
proc = os.proc(
TestUtil.cli,
"--power",
"package",
inputPath.toString(),
"--watch",
"--watch-clear-screen",
extraOptions
)
.spawn(cwd = root, mergeErrIntoOut = true),
timeout = 120.seconds
) { (proc, timeout, ec) =>
var line = TestUtil.readLine(proc.stdout, ec, timeout)
while (!line.contains("Watching sources"))
line = TestUtil.readLine(proc.stdout, ec, timeout)
os.write.over(root / inputPath, code("Hello2"))
line = TestUtil.readLine(proc.stdout, ec, timeout)
while (!line.contains("Watching sources") && !line.contains("\u001b[2J"))
line = TestUtil.readLine(proc.stdout, ec, timeout)
expect(line.contains("Watching sources") || line.contains("\u001b[2J"))
}
}
}
}
Loading
Loading