@@ -3,9 +3,11 @@ package mill.runner
33import mill .api .internal
44import mill .util .{Colors , Watchable }
55import mill .api .SystemStreams
6+ import mill .main .client .DebugLog
67
78import java .io .InputStream
89import scala .annotation .tailrec
10+ import scala .util .Using
911
1012/**
1113 * Logic around the "watch and wait" functionality in Mill: re-run on change,
@@ -15,40 +17,65 @@ import scala.annotation.tailrec
1517object Watching {
1618 case class Result [T ](watched : Seq [Watchable ], error : Option [String ], result : T )
1719
20+ trait Evaluate [T ] {
21+ def apply (enterKeyPressed : Boolean , previousState : Option [T ]): Result [T ]
22+ }
23+
24+ case class WatchArgs (
25+ setIdle : Boolean => Unit ,
26+ colors : Colors
27+ )
28+
1829 def watchLoop [T ](
1930 ringBell : Boolean ,
20- watch : Boolean ,
31+ watch : Option [ WatchArgs ] ,
2132 streams : SystemStreams ,
22- setIdle : Boolean => Unit ,
23- evaluate : (Boolean , Option [T ]) => Result [T ],
24- colors : Colors
33+ evaluate : Evaluate [T ],
2534 ): (Boolean , T ) = {
26- var prevState : Option [T ] = None
27- var enterKeyPressed = false
28- while (true ) {
29- val Result (watchables, errorOpt, result) = evaluate(enterKeyPressed, prevState)
30- prevState = Some (result)
35+ def handleError (errorOpt : Option [String ]): Unit = {
3136 errorOpt.foreach(streams.err.println)
32- if (ringBell) {
33- if (errorOpt.isEmpty) println(" \u0007 " )
34- else {
35- println(" \u0007 " )
36- Thread .sleep(250 )
37- println(" \u0007 " )
38- }
39- }
37+ doRingBell(hasError = errorOpt.isDefined)
38+ }
4039
41- if (! watch) {
42- return (errorOpt.isEmpty, result)
43- }
40+ def doRingBell (hasError : Boolean ): Unit = {
41+ if (! ringBell) return
4442
45- val alreadyStale = watchables.exists(! _.validate())
46- enterKeyPressed = false
47- if (! alreadyStale) {
48- enterKeyPressed = Watching .watchAndWait(streams, setIdle, streams.in, watchables, colors)
43+ println(" \u0007 " )
44+ if (hasError) {
45+ // If we have an error ring the bell again
46+ Thread .sleep(250 )
47+ println(" \u0007 " )
4948 }
5049 }
51- ???
50+
51+ watch match {
52+ case None =>
53+ val Result (watchables, errorOpt, result) =
54+ evaluate(enterKeyPressed = false , previousState = None )
55+ handleError(errorOpt)
56+ (errorOpt.isEmpty, result)
57+
58+ case Some (watchArgs) =>
59+ var prevState : Option [T ] = None
60+ var enterKeyPressed = false
61+
62+ // Exits when the thread gets interruped.
63+ while (true ) {
64+ val Result (watchables, errorOpt, result) = evaluate(enterKeyPressed, prevState)
65+ prevState = Some (result)
66+ handleError(errorOpt)
67+
68+ // Do not enter watch if already stale, re-evaluate instantly.
69+ val alreadyStale = watchables.exists(w => ! w.validate())
70+ if (alreadyStale) {
71+ enterKeyPressed = false
72+ } else {
73+ enterKeyPressed =
74+ watchAndWait(streams, watchArgs.setIdle, streams.in, watchables, watchArgs.colors)
75+ }
76+ }
77+ throw new IllegalStateException (" unreachable" )
78+ }
5279 }
5380
5481 def watchAndWait (
@@ -59,28 +86,88 @@ object Watching {
5986 colors : Colors
6087 ): Boolean = {
6188 setIdle(true )
62- val watchedPaths = watched.collect { case p : Watchable .Path => p.p.path }
63- val watchedValues = watched.size - watchedPaths.size
89+ val (watchedPollables, watchedPathsSeq) = watched.partitionMap {
90+ case w : Watchable .Value => Left (w)
91+ case p : Watchable .Path => Right (p)
92+ }
93+ val watchedPathsSet = watchedPathsSeq.iterator.map(p => p.p.path).toSet
94+ val watchedValueCount = watched.size - watchedPathsSeq.size
6495
65- val watchedValueStr = if (watchedValues == 0 ) " " else s " and $watchedValues other values "
96+ val watchedValueStr =
97+ if (watchedValueCount == 0 ) " " else s " and $watchedValueCount other values "
6698
6799 streams.err.println(
68100 colors.info(
69- s " Watching for changes to ${watchedPaths .size} paths $watchedValueStr... (Enter to re-run, Ctrl-C to exit) "
101+ s " Watching for changes to ${watchedPathsSeq .size} paths $watchedValueStr... (Enter to re-run, Ctrl-C to exit) "
70102 ).toString
71103 )
72104
73- val enterKeyPressed = statWatchWait(watched, stdin)
74- setIdle(false )
75- enterKeyPressed
105+ @ volatile var pathChangesDetected = false
106+
107+ // oslib watch only works with folders, so we have to watch the parent folders instead
108+
109+ mill.main.client.DebugLog .println(
110+ colors.info(s " [watch:watched-paths:unfiltered] ${watchedPathsSet.toSeq.sorted.mkString(" \n " )}" ).toString
111+ )
112+
113+ val ignoredFolders = Seq (
114+ mill.api.WorkspaceRoot .workspaceRoot / " out" ,
115+ mill.api.WorkspaceRoot .workspaceRoot / " .bloop" ,
116+ mill.api.WorkspaceRoot .workspaceRoot / " .metals" ,
117+ mill.api.WorkspaceRoot .workspaceRoot / " .idea" ,
118+ mill.api.WorkspaceRoot .workspaceRoot / " .git" ,
119+ mill.api.WorkspaceRoot .workspaceRoot / " .bsp" ,
120+ )
121+ mill.main.client.DebugLog .println(
122+ colors.info(s " [watch:ignored-paths] ${ignoredFolders.toSeq.sorted.mkString(" \n " )}" ).toString
123+ )
124+
125+ val osLibWatchPaths = watchedPathsSet.iterator.map(p => p / " .." ).toSet
126+ mill.main.client.DebugLog .println(
127+ colors.info(s " [watch:watched-paths] ${osLibWatchPaths.toSeq.sorted.mkString(" \n " )}" ).toString
128+ )
129+
130+ Using .resource(os.watch.watch(
131+ osLibWatchPaths.toSeq,
132+ // filter = path => {
133+ // val shouldBeIgnored = ignoredFolders.exists(ignored => path.startsWith(ignored))
134+ // mill.main.client.DebugLog.println(
135+ // colors.info(s"[watch:filter] $path (ignored=$shouldBeIgnored), ignoredFolders=${ignoredFolders.mkString("[\n ", "\n ", "\n]")}").toString
136+ // )
137+ // !shouldBeIgnored
138+ // },
139+ onEvent = changedPaths => {
140+ // Make sure that the changed paths are actually the ones in our watch list and not some adjacent files in the
141+ // same folder
142+ val hasWatchedPath = changedPaths.exists(p => watchedPathsSet.exists(watchedPath => p.startsWith(watchedPath)))
143+ mill.main.client.DebugLog .println(colors.info(
144+ s " [watch:changed-paths] (hasWatchedPath= $hasWatchedPath) ${changedPaths.mkString(" \n " )}"
145+ ).toString)
146+ if (hasWatchedPath) {
147+ pathChangesDetected = true
148+ }
149+ },
150+ logger = (eventType, data) => {
151+ mill.main.client.DebugLog .println(colors.info(s " [watch] $eventType: ${pprint.apply(data)}" ).toString)
152+ }
153+ )) { _ =>
154+ val enterKeyPressed =
155+ statWatchWait(watchedPollables, stdin, notifiablesChanged = () => pathChangesDetected)
156+ setIdle(false )
157+ enterKeyPressed
158+ }
76159 }
77160
78- // Returns `true` if enter key is pressed to re-run tasks explicitly
79- def statWatchWait (watched : Seq [Watchable ], stdin : InputStream ): Boolean = {
161+ /**
162+ * @param notifiablesChanged returns true if any of the notifiables have changed
163+ *
164+ * @return `true` if enter key is pressed to re-run tasks explicitly, false if changes in watched files occured.
165+ */
166+ def statWatchWait (watched : Seq [Watchable ], stdin : InputStream , notifiablesChanged : () => Boolean ): Boolean = {
80167 val buffer = new Array [Byte ](4 * 1024 )
81168
82169 @ tailrec def statWatchWait0 (): Boolean = {
83- if (watched.forall(_.validate())) {
170+ if (! notifiablesChanged() && watched.forall(_.validate())) {
84171 if (lookForEnterKey()) {
85172 true
86173 } else {
@@ -94,17 +181,18 @@ object Watching {
94181 if (stdin.available() == 0 ) false
95182 else stdin.read(buffer) match {
96183 case 0 | - 1 => false
97- case n =>
184+ case bytesRead =>
98185 buffer.indexOf('\n ' ) match {
99186 case - 1 => lookForEnterKey()
100- case i =>
101- if (i >= n) lookForEnterKey()
187+ case index =>
188+ // If we found the newline further than the bytes read, that means it's not from this read and thus we
189+ // should try reading again.
190+ if (index >= bytesRead) lookForEnterKey()
102191 else true
103192 }
104193 }
105194 }
106195
107196 statWatchWait0()
108197 }
109-
110198}
0 commit comments