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