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
20const 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
26enum 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
74fn create_watcher(args: &CliArgs, tx: mpsc::Sender<notify::Result<Event>>) -> Result<WatcherImpl> {
76 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 match args.watch_file {
86 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 Some(WatchFileKind::UseFsEvents)
98 | Some(WatchFileKind::UseFsEventsOnParentDirectory)
99 | None => {
100 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 if !args.preserve_watch_output {
194 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}