Skip to main content

tsz_cli/
watch.rs

1use anyhow::{Context, Result, bail};
2use notify::{Config, Event, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher};
3use std::collections::BTreeSet;
4
5use rustc_hash::FxHashSet;
6use std::io::IsTerminal;
7use std::path::{Path, PathBuf};
8use std::sync::mpsc;
9use std::time::{Duration, Instant};
10
11use crate::args::{CliArgs, PollingWatchKind, WatchFileKind};
12use crate::config::{ResolvedCompilerOptions, resolve_compiler_options};
13use crate::driver::{self, CompilationCache};
14use crate::driver_resolution::canonicalize_or_owned;
15use crate::fs::{DEFAULT_EXCLUDES, is_ts_file};
16use crate::reporter::Reporter;
17
18const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(200);
19const DEBOUNCE_TICK: Duration = Duration::from_millis(50);
20
21/// Polling intervals for different strategies (matching tsc)
22const FIXED_POLLING_INTERVAL: Duration = Duration::from_millis(250);
23const PRIORITY_POLLING_INTERVAL_MEDIUM: Duration = Duration::from_millis(500);
24const DYNAMIC_PRIORITY_POLLING_DEFAULT: Duration = Duration::from_millis(500);
25const FIXED_CHUNK_SIZE_POLLING: Duration = Duration::from_millis(2000);
26
27/// Wrapper for different watcher types
28enum WatcherImpl {
29    Native(RecommendedWatcher),
30    Poll(PollWatcher),
31}
32
33impl WatcherImpl {
34    fn watch(&mut self, path: &Path, mode: RecursiveMode) -> notify::Result<()> {
35        match self {
36            Self::Native(w) => w.watch(path, mode),
37            Self::Poll(w) => w.watch(path, mode),
38        }
39    }
40}
41
42pub fn run(args: &CliArgs, cwd: &Path) -> Result<()> {
43    let cwd = canonicalize_or_owned(cwd);
44    let color = std::io::stdout().is_terminal();
45    let mut reporter = Reporter::new(color);
46    let mut state = WatchState::new(args, &cwd);
47
48    state.compile_and_report(args, &cwd, &mut reporter, None)?;
49
50    let (tx, rx) = mpsc::channel();
51    let mut watcher = create_watcher(args, tx)?;
52
53    for root in &state.watch_roots {
54        watcher
55            .watch(root, RecursiveMode::Recursive)
56            .with_context(|| format!("failed to watch {}", root.display()))?;
57    }
58
59    loop {
60        match rx.recv_timeout(DEBOUNCE_TICK) {
61            Ok(Ok(event)) => state.handle_event(event),
62            Ok(Err(err)) => println!("watch error: {err}"),
63            Err(mpsc::RecvTimeoutError::Timeout) => {}
64            Err(mpsc::RecvTimeoutError::Disconnected) => {
65                bail!("watch channel disconnected");
66            }
67        }
68
69        if let Some(changed) = state.debouncer.flush_ready(Instant::now()) {
70            state.compile_and_report(args, &cwd, &mut reporter, Some(changed))?;
71        }
72    }
73}
74
75/// Create a watcher based on the specified watch strategy
76fn create_watcher(args: &CliArgs, tx: mpsc::Sender<notify::Result<Event>>) -> Result<WatcherImpl> {
77    // Determine polling interval for polling mode
78    let poll_interval = match args.fallback_polling {
79        Some(PollingWatchKind::FixedInterval) | None => FIXED_POLLING_INTERVAL,
80        Some(PollingWatchKind::PriorityInterval) => PRIORITY_POLLING_INTERVAL_MEDIUM,
81        Some(PollingWatchKind::DynamicPriority) => DYNAMIC_PRIORITY_POLLING_DEFAULT,
82        Some(PollingWatchKind::FixedChunkSize) => FIXED_CHUNK_SIZE_POLLING,
83    };
84
85    // Determine which watcher to use based on watch_file strategy
86    match args.watch_file {
87        // Use polling for these strategies
88        Some(WatchFileKind::FixedPollingInterval)
89        | Some(WatchFileKind::PriorityPollingInterval)
90        | Some(WatchFileKind::DynamicPriorityPolling)
91        | Some(WatchFileKind::FixedChunkSizePolling) => {
92            let config = Config::default().with_poll_interval(poll_interval);
93            let watcher =
94                PollWatcher::new(tx, config).context("failed to initialize poll watcher")?;
95            Ok(WatcherImpl::Poll(watcher))
96        }
97        // Use native file system events (default and UseFsEvents strategies)
98        Some(WatchFileKind::UseFsEvents)
99        | Some(WatchFileKind::UseFsEventsOnParentDirectory)
100        | None => {
101            // Try native watcher first, fall back to polling if it fails
102            match RecommendedWatcher::new(tx.clone(), Config::default()) {
103                Ok(watcher) => Ok(WatcherImpl::Native(watcher)),
104                Err(e) => {
105                    println!("Warning: Native file watcher failed ({e}), falling back to polling");
106                    let config = Config::default().with_poll_interval(poll_interval);
107                    let watcher = PollWatcher::new(tx, config)
108                        .context("failed to initialize fallback poll watcher")?;
109                    Ok(WatcherImpl::Poll(watcher))
110                }
111            }
112        }
113    }
114}
115
116struct WatchState {
117    base_dir: PathBuf,
118    watch_roots: Vec<PathBuf>,
119    filter: WatchFilter,
120    debouncer: Debouncer,
121    type_cache: CompilationCache,
122}
123
124impl WatchState {
125    fn new(args: &CliArgs, cwd: &Path) -> Self {
126        let ProjectState {
127            base_dir,
128            resolved,
129            tsconfig_path,
130        } = load_project_state(args, cwd).unwrap_or_else(|err| {
131            println!("{err}");
132            ProjectState {
133                base_dir: canonicalize_or_owned(cwd),
134                resolved: ResolvedCompilerOptions::default(),
135                tsconfig_path: None,
136            }
137        });
138
139        let explicit_files = resolve_explicit_files(&base_dir, &args.files);
140        let watch_roots = collect_watch_roots(&base_dir, explicit_files.as_ref());
141        let ignore_dirs = compute_ignore_dirs(&base_dir, &resolved);
142        let project_config = if args.project.is_some() {
143            tsconfig_path
144        } else {
145            None
146        };
147
148        Self {
149            base_dir,
150            watch_roots,
151            filter: WatchFilter::new(explicit_files, ignore_dirs, project_config),
152            debouncer: Debouncer::new(DEFAULT_DEBOUNCE),
153            type_cache: CompilationCache::default(),
154        }
155    }
156
157    fn handle_event(&mut self, event: Event) {
158        if !is_relevant_event(event.kind) {
159            return;
160        }
161
162        let now = Instant::now();
163        for path in event.paths {
164            let path = canonicalize_or_owned(&normalize_event_path(&self.base_dir, &path));
165            if self.filter.should_record(&path) {
166                self.debouncer.record_at(now, path);
167            }
168        }
169    }
170
171    fn compile_and_report(
172        &mut self,
173        args: &CliArgs,
174        cwd: &Path,
175        reporter: &mut Reporter,
176        changed_paths: Option<Vec<PathBuf>>,
177    ) -> Result<()> {
178        let changed_paths_ref = changed_paths.as_deref();
179        let needs_full_rebuild =
180            changed_paths_ref.is_some_and(|paths| self.needs_full_rebuild(paths));
181        if needs_full_rebuild {
182            self.type_cache.clear();
183        }
184
185        let result = if needs_full_rebuild || changed_paths_ref.is_none() {
186            driver::compile_with_cache(args, cwd, &mut self.type_cache)
187        } else if let Some(changed_paths) = changed_paths_ref {
188            driver::compile_with_cache_and_changes(args, cwd, &mut self.type_cache, changed_paths)
189        } else {
190            driver::compile_with_cache(args, cwd, &mut self.type_cache)
191        };
192
193        // Clear console unless --preserveWatchOutput is set
194        if !args.preserve_watch_output {
195            // Clear screen (ANSI escape sequence)
196            print!("\x1B[2J\x1B[H");
197        }
198
199        match result {
200            Ok(result) => {
201                if !result.diagnostics.is_empty() {
202                    let output = reporter.render(&result.diagnostics);
203                    if !output.is_empty() {
204                        println!("{output}");
205                    }
206                }
207                self.update_emitted(result.emitted_files);
208            }
209            Err(err) => println!("{err}"),
210        }
211
212        if let Ok(project) = load_project_state(args, cwd) {
213            self.filter.ignore_dirs = compute_ignore_dirs(&project.base_dir, &project.resolved);
214            if args.project.is_some() {
215                self.filter.project_config = project.tsconfig_path;
216            }
217        }
218
219        Ok(())
220    }
221
222    fn needs_full_rebuild(&self, paths: &[PathBuf]) -> bool {
223        paths
224            .iter()
225            .map(|path| canonicalize_or_owned(path))
226            .any(|path| self.is_config_path(&path))
227    }
228
229    fn is_config_path(&self, path: &Path) -> bool {
230        if let Some(project_config) = &self.filter.project_config {
231            path == project_config
232        } else {
233            is_tsconfig_path(path)
234        }
235    }
236
237    fn update_emitted(&mut self, emitted_files: Vec<PathBuf>) {
238        let mut normalized = Vec::with_capacity(emitted_files.len());
239        for path in emitted_files {
240            normalized.push(normalize_event_path(&self.base_dir, &path));
241        }
242        self.filter.set_last_emitted(normalized);
243        self.debouncer.remove_paths(&self.filter.last_emitted);
244    }
245}
246
247struct ProjectState {
248    base_dir: PathBuf,
249    resolved: ResolvedCompilerOptions,
250    tsconfig_path: Option<PathBuf>,
251}
252
253fn load_project_state(args: &CliArgs, cwd: &Path) -> Result<ProjectState> {
254    let tsconfig_path = driver::resolve_tsconfig_path(cwd, args.project.as_deref())?;
255    let config = driver::load_config(tsconfig_path.as_deref())?;
256
257    let mut resolved = resolve_compiler_options(
258        config
259            .as_ref()
260            .and_then(|cfg| cfg.compiler_options.as_ref()),
261    )?;
262    driver::apply_cli_overrides(&mut resolved, args)?;
263
264    let base_dir = driver::config_base_dir(cwd, tsconfig_path.as_deref());
265    let base_dir = canonicalize_or_owned(&base_dir);
266
267    Ok(ProjectState {
268        base_dir,
269        resolved,
270        tsconfig_path,
271    })
272}
273
274fn compute_ignore_dirs(base_dir: &Path, resolved: &ResolvedCompilerOptions) -> Vec<PathBuf> {
275    let mut dirs = BTreeSet::new();
276    for name in DEFAULT_EXCLUDES {
277        dirs.insert(base_dir.join(name));
278    }
279    if let Some(out_dir) = driver::normalize_output_dir(base_dir, resolved.out_dir.clone()) {
280        dirs.insert(out_dir);
281    }
282    if let Some(declaration_dir) =
283        driver::normalize_output_dir(base_dir, resolved.declaration_dir.clone())
284    {
285        dirs.insert(declaration_dir);
286    }
287    dirs.into_iter().collect()
288}
289
290fn collect_watch_roots(
291    base_dir: &Path,
292    explicit_files: Option<&FxHashSet<PathBuf>>,
293) -> Vec<PathBuf> {
294    let mut roots = BTreeSet::new();
295    roots.insert(base_dir.to_path_buf());
296
297    if let Some(files) = explicit_files {
298        for file in files {
299            if let Some(parent) = file.parent() {
300                roots.insert(parent.to_path_buf());
301            }
302        }
303    }
304
305    roots.into_iter().collect()
306}
307
308fn resolve_explicit_files(base_dir: &Path, files: &[PathBuf]) -> Option<FxHashSet<PathBuf>> {
309    if files.is_empty() {
310        return None;
311    }
312
313    let mut resolved = FxHashSet::default();
314    for file in files {
315        let path = if file.is_absolute() {
316            file.to_path_buf()
317        } else {
318            base_dir.join(file)
319        };
320        resolved.insert(path);
321    }
322
323    Some(resolved)
324}
325
326const fn is_relevant_event(kind: EventKind) -> bool {
327    matches!(
328        kind,
329        EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) | EventKind::Any
330    )
331}
332
333fn is_tsconfig_path(path: &Path) -> bool {
334    path.file_name()
335        .and_then(|name| name.to_str())
336        .is_some_and(|name| name == "tsconfig.json")
337}
338
339fn is_default_excluded(path: &Path) -> bool {
340    path.components().any(|component| {
341        let std::path::Component::Normal(name) = component else {
342            return false;
343        };
344        DEFAULT_EXCLUDES
345            .iter()
346            .any(|exclude| name == std::ffi::OsStr::new(exclude))
347    })
348}
349
350fn normalize_event_path(base_dir: &Path, path: &Path) -> PathBuf {
351    if path.is_absolute() {
352        path.to_path_buf()
353    } else {
354        base_dir.join(path)
355    }
356}
357
358pub(crate) struct WatchFilter {
359    explicit_files: Option<FxHashSet<PathBuf>>,
360    ignore_dirs: Vec<PathBuf>,
361    last_emitted: FxHashSet<PathBuf>,
362    project_config: Option<PathBuf>,
363}
364
365impl WatchFilter {
366    pub(crate) fn new(
367        explicit_files: Option<FxHashSet<PathBuf>>,
368        ignore_dirs: Vec<PathBuf>,
369        project_config: Option<PathBuf>,
370    ) -> Self {
371        Self {
372            explicit_files,
373            ignore_dirs,
374            last_emitted: FxHashSet::default(),
375            project_config,
376        }
377    }
378
379    pub(crate) fn set_last_emitted<I>(&mut self, emitted: I)
380    where
381        I: IntoIterator<Item = PathBuf>,
382    {
383        self.last_emitted.clear();
384        for path in emitted {
385            self.last_emitted.insert(path);
386        }
387    }
388
389    pub(crate) fn should_record(&self, path: &Path) -> bool {
390        if self.last_emitted.contains(path) {
391            return false;
392        }
393
394        if let Some(project_config) = &self.project_config {
395            if path == project_config {
396                return true;
397            }
398        } else if is_tsconfig_path(path) {
399            return true;
400        }
401
402        if self.ignore_dirs.iter().any(|dir| path.starts_with(dir)) {
403            return false;
404        }
405
406        if is_default_excluded(path) {
407            return false;
408        }
409
410        if !is_ts_file(path) {
411            return false;
412        }
413
414        if let Some(explicit) = &self.explicit_files {
415            return explicit.contains(path);
416        }
417
418        true
419    }
420}
421
422pub(crate) struct Debouncer {
423    delay: Duration,
424    pending: FxHashSet<PathBuf>,
425    last_event_at: Option<Instant>,
426}
427
428impl Debouncer {
429    pub(crate) fn new(delay: Duration) -> Self {
430        Self {
431            delay,
432            pending: FxHashSet::default(),
433            last_event_at: None,
434        }
435    }
436
437    pub(crate) fn record_at(&mut self, now: Instant, path: PathBuf) {
438        self.pending.insert(path);
439        self.last_event_at = Some(now);
440    }
441
442    pub(crate) fn flush_ready(&mut self, now: Instant) -> Option<Vec<PathBuf>> {
443        let last = self.last_event_at?;
444
445        if now.duration_since(last) < self.delay || self.pending.is_empty() {
446            return None;
447        }
448
449        self.last_event_at = None;
450        Some(self.pending.drain().collect())
451    }
452
453    pub(crate) fn remove_paths(&mut self, paths: &FxHashSet<PathBuf>) {
454        for path in paths {
455            self.pending.remove(path);
456        }
457
458        if self.pending.is_empty() {
459            self.last_event_at = None;
460        }
461    }
462}