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
21const 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
27enum 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
75fn create_watcher(args: &CliArgs, tx: mpsc::Sender<notify::Result<Event>>) -> Result<WatcherImpl> {
77 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 match args.watch_file {
87 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 Some(WatchFileKind::UseFsEvents)
99 | Some(WatchFileKind::UseFsEventsOnParentDirectory)
100 | None => {
101 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 if !args.preserve_watch_output {
195 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}