11use crate :: file_change_event:: FileChangeEvent ;
2+ use rolldown_common:: WatcherChangeKind ;
3+ use rolldown_utils:: indexmap:: FxIndexMap ;
24use std:: time:: { Duration , Instant } ;
35
46/// The state machine for the watcher
@@ -16,32 +18,36 @@ pub enum WatcherState {
1618 #[ default]
1719 Idle ,
1820 /// Collecting changes before triggering a build
19- Debouncing { changes : Vec < FileChangeEvent > , deadline : Instant } ,
21+ Debouncing { changes : FxIndexMap < String , WatcherChangeKind > , deadline : Instant } ,
2022 /// Watcher is closing
2123 Closing ,
2224 /// Watcher has closed
2325 Closed ,
2426}
2527
2628impl WatcherState {
27- /// Handle a file change event
29+ /// Handle a batch of file change events
2830 ///
29- /// Returns the new state after processing the file change
31+ /// Returns the new state after processing the file changes.
32+ /// See "Kind Consolidation" in `meta/design/watch-mode.md` for dedup rules.
3033 #[ must_use]
31- pub fn on_file_change ( self , entry : FileChangeEvent , debounce_duration : Duration ) -> Self {
34+ pub fn on_file_changes ( self , entries : Vec < FileChangeEvent > , debounce_duration : Duration ) -> Self {
35+ if entries. is_empty ( ) {
36+ return self ;
37+ }
3238 match self {
3339 WatcherState :: Idle => {
40+ let mut changes = FxIndexMap :: default ( ) ;
41+ for entry in entries {
42+ merge_change_kind ( & mut changes, entry. path , entry. kind ) ;
43+ }
3444 let deadline = Instant :: now ( ) + debounce_duration;
35- WatcherState :: Debouncing { changes : vec ! [ entry ] , deadline }
45+ WatcherState :: Debouncing { changes, deadline }
3646 }
3747 WatcherState :: Debouncing { mut changes, .. } => {
38- // Check if we already have a change for this path
39- if let Some ( existing) = changes. iter_mut ( ) . find ( |c| c. path == entry. path ) {
40- existing. kind = entry. kind ;
41- } else {
42- changes. push ( entry) ;
48+ for entry in entries {
49+ merge_change_kind ( & mut changes, entry. path , entry. kind ) ;
4350 }
44- // Reset the deadline
4551 let deadline = Instant :: now ( ) + debounce_duration;
4652 WatcherState :: Debouncing { changes, deadline }
4753 }
@@ -54,9 +60,15 @@ impl WatcherState {
5460 ///
5561 /// Returns (new_state, changes_to_build) if transitioning to Idle,
5662 /// otherwise returns (self, None)
57- pub fn on_debounce_timeout ( self ) -> ( Self , Option < Vec < FileChangeEvent > > ) {
63+ pub fn on_debounce_timeout ( self ) -> ( Self , Option < FxIndexMap < String , WatcherChangeKind > > ) {
5864 match self {
59- WatcherState :: Debouncing { changes, .. } => ( WatcherState :: Idle , Some ( changes) ) ,
65+ WatcherState :: Debouncing { changes, .. } => {
66+ if changes. is_empty ( ) {
67+ ( WatcherState :: Idle , None )
68+ } else {
69+ ( WatcherState :: Idle , Some ( changes) )
70+ }
71+ }
6072 other => ( other, None ) ,
6173 }
6274 }
@@ -107,32 +119,61 @@ impl WatcherState {
107119 }
108120}
109121
122+ /// Merge a new change kind into the accumulated changes for a path.
123+ ///
124+ /// See "Kind Consolidation" in `meta/design/watch-mode.md` for the full rule table.
125+ fn merge_change_kind (
126+ changes : & mut FxIndexMap < String , WatcherChangeKind > ,
127+ path : String ,
128+ new_kind : WatcherChangeKind ,
129+ ) {
130+ if let Some ( old_kind) = changes. get ( & path) . copied ( ) {
131+ match ( old_kind, new_kind) {
132+ // File was created then modified — still a creation
133+ ( WatcherChangeKind :: Create , WatcherChangeKind :: Update ) => { }
134+ // File was created then deleted — cancel out entirely
135+ ( WatcherChangeKind :: Create , WatcherChangeKind :: Delete ) => {
136+ changes. swap_remove ( & path) ;
137+ }
138+ // File was deleted then recreated — net effect is an update
139+ ( WatcherChangeKind :: Delete , WatcherChangeKind :: Create ) => {
140+ changes. insert ( path, WatcherChangeKind :: Update ) ;
141+ }
142+ // All other cases: new kind wins
143+ _ => {
144+ changes. insert ( path, new_kind) ;
145+ }
146+ }
147+ } else {
148+ changes. insert ( path, new_kind) ;
149+ }
150+ }
151+
110152#[ cfg( test) ]
111153mod tests {
112154 use super :: * ;
113- use rolldown_common:: WatcherChangeKind ;
114155
115156 fn default_duration ( ) -> Duration {
116157 Duration :: from_millis ( 100 )
117158 }
118159
119160 #[ test]
120- fn test_idle_to_debouncing_on_file_change ( ) {
161+ fn test_idle_to_debouncing_on_file_changes ( ) {
121162 let state = WatcherState :: Idle ;
122- let entry = FileChangeEvent :: new ( "test.js" . into ( ) , WatcherChangeKind :: Update ) ;
123- let new_state = state. on_file_change ( entry , default_duration ( ) ) ;
163+ let entries = vec ! [ FileChangeEvent :: new( "test.js" . into( ) , WatcherChangeKind :: Update ) ] ;
164+ let new_state = state. on_file_changes ( entries , default_duration ( ) ) ;
124165
125166 assert ! ( new_state. is_debouncing( ) ) ;
126167 }
127168
128169 #[ test]
129170 fn test_debouncing_accumulates_changes ( ) {
130171 let state = WatcherState :: Idle ;
131- let entry1 = FileChangeEvent :: new ( "test1.js" . into ( ) , WatcherChangeKind :: Update ) ;
132- let entry2 = FileChangeEvent :: new ( "test2.js" . into ( ) , WatcherChangeKind :: Create ) ;
172+ let batch1 = vec ! [ FileChangeEvent :: new( "test1.js" . into( ) , WatcherChangeKind :: Update ) ] ;
173+ let batch2 = vec ! [ FileChangeEvent :: new( "test2.js" . into( ) , WatcherChangeKind :: Create ) ] ;
133174
134- let state = state. on_file_change ( entry1 , default_duration ( ) ) ;
135- let state = state. on_file_change ( entry2 , default_duration ( ) ) ;
175+ let state = state. on_file_changes ( batch1 , default_duration ( ) ) ;
176+ let state = state. on_file_changes ( batch2 , default_duration ( ) ) ;
136177
137178 if let WatcherState :: Debouncing { changes, .. } = state {
138179 assert_eq ! ( changes. len( ) , 2 ) ;
@@ -143,10 +184,9 @@ mod tests {
143184
144185 #[ test]
145186 fn test_debounce_timeout_to_idle ( ) {
146- let state = WatcherState :: Debouncing {
147- changes : vec ! [ FileChangeEvent :: new( "test.js" . into( ) , WatcherChangeKind :: Update ) ] ,
148- deadline : Instant :: now ( ) ,
149- } ;
187+ let mut changes = FxIndexMap :: default ( ) ;
188+ changes. insert ( "test.js" . to_string ( ) , WatcherChangeKind :: Update ) ;
189+ let state = WatcherState :: Debouncing { changes, deadline : Instant :: now ( ) } ;
150190
151191 let ( new_state, changes) = state. on_debounce_timeout ( ) ;
152192
@@ -156,22 +196,117 @@ mod tests {
156196 }
157197
158198 #[ test]
159- fn test_debouncing_deduplicates_same_path ( ) {
199+ fn test_create_then_update_consolidates_to_create ( ) {
200+ let state = WatcherState :: Idle ;
201+ let batch1 = vec ! [ FileChangeEvent :: new( "test.js" . into( ) , WatcherChangeKind :: Create ) ] ;
202+ let batch2 = vec ! [ FileChangeEvent :: new( "test.js" . into( ) , WatcherChangeKind :: Update ) ] ;
203+
204+ let state = state. on_file_changes ( batch1, default_duration ( ) ) ;
205+ let state = state. on_file_changes ( batch2, default_duration ( ) ) ;
206+
207+ if let WatcherState :: Debouncing { changes, .. } = state {
208+ assert_eq ! ( changes. len( ) , 1 ) ;
209+ assert_eq ! ( changes[ "test.js" ] , WatcherChangeKind :: Create ) ;
210+ } else {
211+ panic ! ( "Expected Debouncing state" ) ;
212+ }
213+ }
214+
215+ #[ test]
216+ fn test_create_then_delete_cancels_out ( ) {
217+ let state = WatcherState :: Idle ;
218+ let batch1 = vec ! [ FileChangeEvent :: new( "test.js" . into( ) , WatcherChangeKind :: Create ) ] ;
219+ let batch2 = vec ! [ FileChangeEvent :: new( "test.js" . into( ) , WatcherChangeKind :: Delete ) ] ;
220+
221+ let state = state. on_file_changes ( batch1, default_duration ( ) ) ;
222+ let state = state. on_file_changes ( batch2, default_duration ( ) ) ;
223+
224+ if let WatcherState :: Debouncing { changes, .. } = state {
225+ assert_eq ! ( changes. len( ) , 0 ) ;
226+ } else {
227+ panic ! ( "Expected Debouncing state" ) ;
228+ }
229+ }
230+
231+ #[ test]
232+ fn test_delete_then_create_consolidates_to_update ( ) {
160233 let state = WatcherState :: Idle ;
161- let entry1 = FileChangeEvent :: new ( "test.js" . into ( ) , WatcherChangeKind :: Create ) ;
162- let entry2 = FileChangeEvent :: new ( "test.js" . into ( ) , WatcherChangeKind :: Update ) ;
234+ let batch1 = vec ! [ FileChangeEvent :: new( "test.js" . into( ) , WatcherChangeKind :: Delete ) ] ;
235+ let batch2 = vec ! [ FileChangeEvent :: new( "test.js" . into( ) , WatcherChangeKind :: Create ) ] ;
236+
237+ let state = state. on_file_changes ( batch1, default_duration ( ) ) ;
238+ let state = state. on_file_changes ( batch2, default_duration ( ) ) ;
239+
240+ if let WatcherState :: Debouncing { changes, .. } = state {
241+ assert_eq ! ( changes. len( ) , 1 ) ;
242+ assert_eq ! ( changes[ "test.js" ] , WatcherChangeKind :: Update ) ;
243+ } else {
244+ panic ! ( "Expected Debouncing state" ) ;
245+ }
246+ }
163247
164- let state = state. on_file_change ( entry1, default_duration ( ) ) ;
165- let state = state. on_file_change ( entry2, default_duration ( ) ) ;
248+ #[ test]
249+ fn test_batch_create_then_update_within_single_call ( ) {
250+ let state = WatcherState :: Idle ;
251+ let entries = vec ! [
252+ FileChangeEvent :: new( "test.js" . into( ) , WatcherChangeKind :: Create ) ,
253+ FileChangeEvent :: new( "test.js" . into( ) , WatcherChangeKind :: Update ) ,
254+ ] ;
255+ let state = state. on_file_changes ( entries, default_duration ( ) ) ;
166256
167257 if let WatcherState :: Debouncing { changes, .. } = state {
168258 assert_eq ! ( changes. len( ) , 1 ) ;
169- assert_eq ! ( changes[ 0 ] . kind , WatcherChangeKind :: Update ) ;
259+ assert_eq ! ( changes[ "test.js" ] , WatcherChangeKind :: Create ) ;
170260 } else {
171261 panic ! ( "Expected Debouncing state" ) ;
172262 }
173263 }
174264
265+ #[ test]
266+ fn test_empty_entries_keeps_idle ( ) {
267+ let state = WatcherState :: Idle ;
268+ let new_state = state. on_file_changes ( vec ! [ ] , default_duration ( ) ) ;
269+ assert ! ( new_state. is_idle( ) ) ;
270+ }
271+
272+ #[ test]
273+ fn test_empty_entries_keeps_debouncing_without_deadline_reset ( ) {
274+ let state = WatcherState :: Idle ;
275+ let entries = vec ! [ FileChangeEvent :: new( "test.js" . into( ) , WatcherChangeKind :: Update ) ] ;
276+ let state = state. on_file_changes ( entries, default_duration ( ) ) ;
277+ let deadline_before = match & state {
278+ WatcherState :: Debouncing { deadline, .. } => * deadline,
279+ _ => panic ! ( "Expected Debouncing state" ) ,
280+ } ;
281+
282+ let state = state. on_file_changes ( vec ! [ ] , default_duration ( ) ) ;
283+
284+ match & state {
285+ WatcherState :: Debouncing { deadline, changes, .. } => {
286+ assert_eq ! ( changes. len( ) , 1 ) ;
287+ assert_eq ! ( * deadline, deadline_before) ;
288+ }
289+ _ => panic ! ( "Expected Debouncing state" ) ,
290+ }
291+ }
292+
293+ #[ test]
294+ fn test_debounce_timeout_with_empty_changes_after_consolidation ( ) {
295+ let state = WatcherState :: Idle ;
296+ let batch1 = vec ! [ FileChangeEvent :: new( "a.js" . into( ) , WatcherChangeKind :: Create ) ] ;
297+ let batch2 = vec ! [ FileChangeEvent :: new( "a.js" . into( ) , WatcherChangeKind :: Delete ) ] ;
298+
299+ let state = state. on_file_changes ( batch1, default_duration ( ) ) ;
300+ let state = state. on_file_changes ( batch2, default_duration ( ) ) ;
301+
302+ // Changes cancelled out — map is empty
303+ assert ! ( state. is_debouncing( ) ) ;
304+
305+ let ( new_state, changes) = state. on_debounce_timeout ( ) ;
306+ assert ! ( new_state. is_idle( ) ) ;
307+ assert ! ( changes. is_none( ) ) ;
308+ }
309+
175310 #[ test]
176311 fn test_close_from_idle ( ) {
177312 let state = WatcherState :: Idle ;
@@ -193,8 +328,8 @@ mod tests {
193328 #[ test]
194329 fn test_closing_ignores_file_changes ( ) {
195330 let state = WatcherState :: Closing ;
196- let entry = FileChangeEvent :: new ( "test.js" . into ( ) , WatcherChangeKind :: Update ) ;
197- let new_state = state. on_file_change ( entry , default_duration ( ) ) ;
331+ let entries = vec ! [ FileChangeEvent :: new( "test.js" . into( ) , WatcherChangeKind :: Update ) ] ;
332+ let new_state = state. on_file_changes ( entries , default_duration ( ) ) ;
198333
199334 assert ! ( matches!( new_state, WatcherState :: Closing ) ) ;
200335 }
0 commit comments