@ape-egg/vibe 2.3.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +14 -4
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +10 -15
  6. package/llms.txt +8 -6
  7. package/package.json +19 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +312 -99
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +251 -111
  15. package/runtime/index.js +180 -71
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +69 -5
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +77 -14
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1196
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2880
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -16
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/spa.rs +0 -477
  47. package/compiler/src/compiler/state_extractor.rs +0 -263
  48. package/compiler/src/compiler/value_stamper.rs +0 -921
  49. package/compiler/src/compiler/watcher.rs +0 -1278
  50. package/compiler/src/config.rs +0 -279
  51. package/compiler/src/main.rs +0 -358
  52. package/compiler/src/parser/element.rs +0 -96
  53. package/compiler/src/parser/html.rs +0 -1004
  54. package/compiler/src/parser/mod.rs +0 -8
  55. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  56. package/runtime/scope.js +0 -50
  57. package/test-results/.last-run.json +0 -4
@@ -1,1278 +0,0 @@
1
- use std::collections::{HashMap, HashSet};
2
- use std::path::{Path, PathBuf};
3
- use std::sync::mpsc::channel;
4
- use std::time::Duration;
5
- use colored::Colorize;
6
- use notify_debouncer_full::{new_debouncer, notify::{RecursiveMode, Watcher}};
7
- use regex::Regex;
8
-
9
- use super::{Compiler, compile::should_skip_path};
10
- use crate::config::Config;
11
-
12
- /// Tracks which pages and components depend on which components
13
- pub struct DependencyGraph {
14
- /// component path -> set of page paths that use it
15
- component_to_pages: HashMap<PathBuf, HashSet<PathBuf>>,
16
- /// component path -> set of component paths that use it (for transitive dependencies)
17
- component_to_components: HashMap<PathBuf, HashSet<PathBuf>>,
18
- /// page path -> set of component paths it uses
19
- page_to_components: HashMap<PathBuf, HashSet<PathBuf>>,
20
- /// component path -> set of component paths it uses (forward edges, so a
21
- /// component's own deps can be cleared on refresh — the mirror of
22
- /// component_to_components)
23
- component_to_used_components: HashMap<PathBuf, HashSet<PathBuf>>,
24
- }
25
-
26
- impl DependencyGraph {
27
- pub fn new() -> Self {
28
- Self {
29
- component_to_pages: HashMap::new(),
30
- component_to_components: HashMap::new(),
31
- page_to_components: HashMap::new(),
32
- component_to_used_components: HashMap::new(),
33
- }
34
- }
35
-
36
- /// Add a dependency: file (page or component) uses component
37
- pub fn add_dependency(&mut self, file: PathBuf, component: PathBuf, file_is_component: bool) {
38
- if file_is_component {
39
- // Component uses another component
40
- self.component_to_components
41
- .entry(component.clone())
42
- .or_insert_with(HashSet::new)
43
- .insert(file.clone());
44
-
45
- self.component_to_used_components
46
- .entry(file)
47
- .or_insert_with(HashSet::new)
48
- .insert(component);
49
- } else {
50
- // Page uses component
51
- self.component_to_pages
52
- .entry(component.clone())
53
- .or_insert_with(HashSet::new)
54
- .insert(file.clone());
55
-
56
- self.page_to_components
57
- .entry(file)
58
- .or_insert_with(HashSet::new)
59
- .insert(component);
60
- }
61
- }
62
-
63
- /// Replace `file`'s outgoing dependency edges with `deps`. Works for both
64
- /// pages and components, so a newly-added `<component src>` reference (or a
65
- /// removed one) is learned the moment the referrer is saved. This is what lets
66
- /// the watcher pick up files created mid-session: referencing a new component
67
- /// always means editing a referrer, and that edit refreshes the graph here.
68
- pub fn refresh_file(&mut self, file: &Path, deps: HashSet<PathBuf>, file_is_component: bool) {
69
- // Clear the file's old outgoing edges (and their reverse entries) so a
70
- // dependency it no longer uses stops mapping back to it.
71
- let forward = if file_is_component {
72
- &mut self.component_to_used_components
73
- } else {
74
- &mut self.page_to_components
75
- };
76
-
77
- if let Some(old_deps) = forward.remove(file) {
78
- let reverse = if file_is_component {
79
- &mut self.component_to_components
80
- } else {
81
- &mut self.component_to_pages
82
- };
83
- for dep in old_deps {
84
- if let Some(users) = reverse.get_mut(&dep) {
85
- users.remove(file);
86
- }
87
- }
88
- }
89
-
90
- // Add the current edges.
91
- for dep in deps {
92
- self.add_dependency(file.to_path_buf(), dep, file_is_component);
93
- }
94
- }
95
-
96
- /// Get all pages that transitively depend on a component (includes component -> component -> page chains)
97
- pub fn get_all_dependent_pages(&self, component: &Path) -> HashSet<PathBuf> {
98
- let mut all_pages = HashSet::new();
99
- let mut visited_components = HashSet::new();
100
- self.collect_dependent_pages(component, &mut all_pages, &mut visited_components);
101
- all_pages
102
- }
103
-
104
- fn collect_dependent_pages(
105
- &self,
106
- component: &Path,
107
- all_pages: &mut HashSet<PathBuf>,
108
- visited: &mut HashSet<PathBuf>,
109
- ) {
110
- // Avoid infinite loops in circular dependencies
111
- if !visited.insert(component.to_path_buf()) {
112
- return;
113
- }
114
-
115
- // Add pages that directly use this component
116
- if let Some(pages) = self.component_to_pages.get(component) {
117
- all_pages.extend(pages.iter().cloned());
118
- }
119
-
120
- // Recursively add pages that use components that use this component
121
- if let Some(dependent_components) = self.component_to_components.get(component) {
122
- for dep_component in dependent_components {
123
- self.collect_dependent_pages(dep_component, all_pages, visited);
124
- }
125
- }
126
- }
127
-
128
- /// Get the edited component itself plus every component that transitively
129
- /// inlines it. These are exactly the component caches that go stale on an
130
- /// edit: a parent's cached content is the *fully inlined* child, so when the
131
- /// child changes the parent's cache is stale too. Unrelated components keep
132
- /// their cache. The returned set always includes `component` itself.
133
- pub fn get_all_dependent_components(&self, component: &Path) -> HashSet<PathBuf> {
134
- let mut all = HashSet::new();
135
- self.collect_dependent_components(component, &mut all);
136
- all
137
- }
138
-
139
- fn collect_dependent_components(&self, component: &Path, all: &mut HashSet<PathBuf>) {
140
- // insert() == false → already seen: doubles as the cycle guard.
141
- if !all.insert(component.to_path_buf()) {
142
- return;
143
- }
144
-
145
- if let Some(users) = self.component_to_components.get(component) {
146
- for user in users {
147
- self.collect_dependent_components(user, all);
148
- }
149
- }
150
- }
151
- }
152
-
153
- /// Extract component references from HTML content
154
- pub fn extract_component_dependencies(html: &str, components_dir: &Path) -> HashSet<PathBuf> {
155
- let mut deps = HashSet::new();
156
-
157
- // 1. <component src="/components/card.html"> - works in both compiled and runtime modes
158
- let component_tag_re = Regex::new(r#"<component\s+[^>]*src=["']([^"']+)["']"#).unwrap();
159
- for cap in component_tag_re.captures_iter(html) {
160
- if let Some(src) = cap.get(1) {
161
- let src_path = src.as_str().trim_start_matches('/');
162
- deps.insert(PathBuf::from(src_path));
163
- }
164
- }
165
-
166
- // 2. Custom elements: <UserCard> -> /components/user-card.html
167
- let custom_element_re = Regex::new(r"<([A-Z][a-zA-Z0-9]*)[\s>]").unwrap();
168
- for cap in custom_element_re.captures_iter(html) {
169
- if let Some(tag) = cap.get(1) {
170
- let kebab = to_kebab_case(tag.as_str());
171
- let component_path = components_dir.join(format!("{}.html", kebab));
172
- // Store relative to source root
173
- if let Ok(rel_path) = component_path.strip_prefix("/") {
174
- deps.insert(rel_path.to_path_buf());
175
- } else {
176
- deps.insert(component_path);
177
- }
178
- }
179
- }
180
-
181
- deps
182
- }
183
-
184
- /// Convert PascalCase to kebab-case
185
- fn to_kebab_case(s: &str) -> String {
186
- let mut result = String::new();
187
- for (i, ch) in s.chars().enumerate() {
188
- if ch.is_uppercase() && i > 0 {
189
- result.push('-');
190
- }
191
- result.push(ch.to_ascii_lowercase());
192
- }
193
- result
194
- }
195
-
196
- /// Map a component's source path to the normalized key under which its inlined
197
- /// content is cached (e.g. `/components/Sidebar.html`). The cache key is the
198
- /// component's path relative to the source root, with a leading slash — the same
199
- /// form `<component src>` resolves to. Returns None for paths outside the source
200
- /// root (e.g. external URL components, which never go stale on a local edit).
201
- fn component_cache_key(component: &Path, canonical_source: &Path) -> Option<String> {
202
- component
203
- .strip_prefix(canonical_source)
204
- .ok()
205
- .and_then(|rel| rel.to_str())
206
- .map(|rel| format!("/{}", rel.replace('\\', "/")))
207
- }
208
-
209
- /// Cross-process guard: exactly one `vibe compile --watch` per output
210
- /// directory. Two concurrent watchers double-compile every save and race each
211
- /// other's output writes — the loser reads a torn file back and emits a
212
- /// valid-but-empty manifest, so the page hydrates blank with no errors. The
213
- /// lock lives in the OS temp dir keyed by the output path, holds the owner's
214
- /// pid, and a lock whose process is gone is stolen (a killed watcher never
215
- /// unwinds, so Drop alone can't be trusted to clean up).
216
- #[derive(Debug)]
217
- pub struct WatchLock {
218
- path: PathBuf,
219
- }
220
-
221
- impl WatchLock {
222
- /// Deterministic lock path for an output dir, stable whether or not the
223
- /// output exists yet: canonicalize the output itself when possible, else
224
- /// its (existing) parent — so a watcher that locked before the first
225
- /// compile created the output still collides with one that locked after.
226
- fn lock_path_for(output: &Path) -> PathBuf {
227
- use std::collections::hash_map::DefaultHasher;
228
- use std::hash::{Hash, Hasher};
229
-
230
- let canonical = output.canonicalize().unwrap_or_else(|_| {
231
- let parent = output.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
232
- let name = output.file_name().map(PathBuf::from).unwrap_or_default();
233
- parent
234
- .canonicalize()
235
- .unwrap_or_else(|_| parent.to_path_buf())
236
- .join(name)
237
- });
238
-
239
- let mut hasher = DefaultHasher::new();
240
- canonical.hash(&mut hasher);
241
- std::env::temp_dir().join(format!("vibe-watch-{:016x}.lock", hasher.finish()))
242
- }
243
-
244
- pub fn acquire(output: &Path) -> Result<Self, String> {
245
- let path = Self::lock_path_for(output);
246
-
247
- // Two attempts: the second runs only after a stale lock was removed.
248
- for _ in 0..2 {
249
- match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
250
- Ok(mut file) => {
251
- use std::io::Write;
252
- let _ = write!(file, "{}", std::process::id());
253
- return Ok(Self { path });
254
- }
255
- Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
256
- let holder = std::fs::read_to_string(&path)
257
- .ok()
258
- .and_then(|s| s.trim().parse::<u32>().ok());
259
- match holder {
260
- Some(pid) if process_alive(pid) => {
261
- return Err(format!(
262
- "another `vibe compile --watch` (pid {}) is already watching this output directory. \
263
- Concurrent watchers race each other's writes and corrupt manifests — stop the other one first. \
264
- (lock: {})",
265
- pid,
266
- path.display()
267
- ));
268
- }
269
- // Dead owner or unreadable lock: stale, steal it.
270
- _ => {
271
- let _ = std::fs::remove_file(&path);
272
- }
273
- }
274
- }
275
- Err(e) => {
276
- return Err(format!(
277
- "failed to create watch lock {}: {}",
278
- path.display(),
279
- e
280
- ));
281
- }
282
- }
283
- }
284
-
285
- Err(format!(
286
- "could not acquire watch lock {} — still held after stale-lock cleanup",
287
- path.display()
288
- ))
289
- }
290
- }
291
-
292
- impl Drop for WatchLock {
293
- fn drop(&mut self) {
294
- let _ = std::fs::remove_file(&self.path);
295
- }
296
- }
297
-
298
- #[cfg(unix)]
299
- fn process_alive(pid: u32) -> bool {
300
- std::process::Command::new("kill")
301
- .arg("-0")
302
- .arg(pid.to_string())
303
- .stdout(std::process::Stdio::null())
304
- .stderr(std::process::Stdio::null())
305
- .status()
306
- .map(|s| s.success())
307
- .unwrap_or(false)
308
- }
309
-
310
- /// Without a portable liveness probe, treat an existing lock as live — failing
311
- /// loudly (with the lock path in the message) beats silently racing.
312
- #[cfg(not(unix))]
313
- fn process_alive(_pid: u32) -> bool {
314
- true
315
- }
316
-
317
- /// Check if a path should be blacklisted based on SKIP_FILES patterns
318
- /// This checks both the filename and all path components relative to source root
319
- fn is_path_blacklisted(path: &Path, source_root: &Path, skip_files: &[String]) -> bool {
320
- let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
321
-
322
- // Check filename against blacklist
323
- if should_skip_path(path, file_name, skip_files) {
324
- return true;
325
- }
326
-
327
- // Check if any path component (relative to source) matches blacklist
328
- if let Ok(relative) = path.strip_prefix(source_root) {
329
- for component in relative.components() {
330
- if let Some(component_str) = component.as_os_str().to_str() {
331
- if should_skip_path(path, component_str, skip_files) {
332
- return true;
333
- }
334
- }
335
- }
336
- }
337
-
338
- false
339
- }
340
-
341
- /// Build dependency graph by scanning all HTML files
342
- pub fn build_dependency_graph(config: &Config) -> std::result::Result<DependencyGraph, std::io::Error> {
343
- let mut graph = DependencyGraph::new();
344
-
345
- // Canonicalize output path for reliable comparison
346
- let canonical_output = config.output.canonicalize()
347
- .unwrap_or_else(|_| config.output.clone());
348
-
349
- // Scan all HTML files in source directory
350
- scan_directory(&config.source, &config.source, &config.components, &canonical_output, &config.skip_files, &mut graph)?;
351
-
352
- Ok(graph)
353
- }
354
-
355
- fn scan_directory(
356
- dir: &Path,
357
- source_root: &Path,
358
- components_dir: &str,
359
- output_dir: &Path,
360
- skip_files: &[String],
361
- graph: &mut DependencyGraph,
362
- ) -> std::result::Result<(), std::io::Error> {
363
- if !dir.is_dir() {
364
- return Ok(());
365
- }
366
-
367
- let components_path = source_root.join(components_dir);
368
-
369
- for entry in std::fs::read_dir(dir)? {
370
- let entry = entry?;
371
- let path = entry.path();
372
- let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
373
-
374
- if path.is_dir() {
375
- // Skip output directory (dynamically check, not hardcoded "compiled")
376
- let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
377
- if canonical_path.starts_with(output_dir) {
378
- continue;
379
- }
380
-
381
- // Skip directories using shared skip logic
382
- if should_skip_path(&path, file_name, skip_files) {
383
- continue;
384
- }
385
-
386
- scan_directory(&path, source_root, components_dir, output_dir, skip_files, graph)?;
387
- } else if let Some(ext) = path.extension() {
388
- if ext == "html" {
389
- let html = std::fs::read_to_string(&path)?;
390
- let deps = extract_component_dependencies(&html, &components_path);
391
-
392
- // Check if this file is itself a component
393
- let file_is_component = path.starts_with(&components_path);
394
-
395
- for dep in deps {
396
- let dep_absolute = source_root.join(&dep);
397
- // Canonicalize to handle case-insensitive filesystems (macOS)
398
- let dep_canonical = dep_absolute.canonicalize().unwrap_or(dep_absolute);
399
- graph.add_dependency(path.clone(), dep_canonical, file_is_component);
400
- }
401
- }
402
- }
403
- }
404
-
405
- Ok(())
406
- }
407
-
408
- /// A watcher whose spawning wrapper dies without unwinding (SIGKILL, crashed
409
- /// dev server) is orphaned: it keeps watching and its lock blocks every future
410
- /// `--watch` on the same output. Reparenting is the orphan signal — when the
411
- /// parent pid changes (to init or a reaper), the owner is gone, so release the
412
- /// lock and exit. `std::process::exit` skips Drop, hence the explicit remove.
413
- #[cfg(unix)]
414
- fn exit_when_orphaned(lock_path: PathBuf) {
415
- let parent = std::os::unix::process::parent_id();
416
- std::thread::spawn(move || loop {
417
- std::thread::sleep(Duration::from_secs(2));
418
- if std::os::unix::process::parent_id() != parent {
419
- eprintln!("parent process exited — shutting down watcher");
420
- let _ = std::fs::remove_file(&lock_path);
421
- std::process::exit(0);
422
- }
423
- });
424
- }
425
-
426
- #[cfg(not(unix))]
427
- fn exit_when_orphaned(_lock_path: PathBuf) {}
428
-
429
- /// A config change invalidates every piece of watcher state — dependency
430
- /// graph, compiler, parser, watch roots, output lock. The cleanest
431
- /// expression is a fresh start: release the lock and exec the same binary
432
- /// with the same argv, so CLI flag overrides re-apply with perfect parity.
433
- /// Same pid — a parent process watching this child never sees an exit.
434
- fn restart_with_fresh_config(lock: WatchLock) -> ! {
435
- println!(
436
- "{} package.json changed — restarting with fresh config",
437
- "[watch]".cyan()
438
- );
439
- drop(lock);
440
- let exe = std::env::current_exe()
441
- .unwrap_or_else(|_| PathBuf::from(std::env::args().next().unwrap_or_default()));
442
- let args: Vec<String> = std::env::args().skip(1).collect();
443
- #[cfg(unix)]
444
- {
445
- use std::os::unix::process::CommandExt;
446
- let err = std::process::Command::new(&exe).args(&args).exec();
447
- eprintln!("{}: watcher restart failed: {}", "Error".red(), err);
448
- std::process::exit(1);
449
- }
450
- #[cfg(not(unix))]
451
- {
452
- let code = std::process::Command::new(&exe)
453
- .args(&args)
454
- .status()
455
- .ok()
456
- .and_then(|status| status.code())
457
- .unwrap_or(1);
458
- std::process::exit(code);
459
- }
460
- }
461
-
462
- /// Start watching for file changes
463
- pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn std::error::Error>> {
464
- // Held for the watcher's whole lifetime; a second watcher on the same
465
- // output exits loudly instead of silently racing this one.
466
- let _watch_lock = WatchLock::acquire(&config.output)?;
467
- exit_when_orphaned(_watch_lock.path.clone());
468
-
469
- println!("{}", "Building dependency graph...".cyan());
470
- let mut graph = build_dependency_graph(&config)?;
471
-
472
- println!("{}", "Running initial compilation...".cyan());
473
- let mut compiler = Compiler::new(config.clone(), verbose);
474
-
475
- match compiler.compile() {
476
- Ok(stats) => {
477
- // Generate manifests BEFORE showing success (unless runtime-as-is is enabled)
478
- let mut manifest_time_ms = 0.0;
479
- let mut manifest_stats_result = None;
480
- if !config.runtime_as_is {
481
- match compiler.generate_manifests() {
482
- Ok(manifest_stats) => {
483
- manifest_time_ms = manifest_stats.total_time_ms;
484
- manifest_stats_result = Some(manifest_stats);
485
- }
486
- Err(e) => {
487
- eprintln!("\n{}: Manifest generation failed: {}", "Warning".yellow(), e);
488
- eprintln!("Compilation succeeded but manifests were not generated.");
489
- }
490
- }
491
- }
492
-
493
- // Show success headline
494
- println!("\n{}", "Initial compilation complete!".green().bold());
495
- println!();
496
-
497
- // Show individual phase timings (same as main.rs output)
498
-
499
- // Show validation time if it happened
500
- if let Some(validation_time) = stats.validation_time_ms {
501
- println!("* Validated components in {:.0}ms", validation_time);
502
- }
503
-
504
- let total_components_unique = stats.internal_components_unique + stats.external_components_unique;
505
-
506
- // Always show components line
507
- if stats.components_as_is {
508
- println!("* Compiled components (0 internal, 0 external) - \"components-as-is\": true");
509
- } else if total_components_unique > 0 {
510
- println!("* Compiled components ({} internal, {} external) in {:.0}ms",
511
- stats.internal_components_unique,
512
- stats.external_components_unique,
513
- stats.components_time_ms
514
- );
515
- }
516
-
517
- if stats.files_compiled > 0 {
518
- println!("* Compiled HTML ({} file{}) in {:.0}ms",
519
- stats.files_compiled,
520
- if stats.files_compiled == 1 { "" } else { "s" },
521
- stats.compile_time_ms
522
- );
523
- }
524
-
525
- // Show manifest stats between HTML and Copied files
526
- if let Some(manifest_stats) = manifest_stats_result {
527
- println!("* Generated manifests ({} file{}, {} skipped) in {:.0}ms",
528
- manifest_stats.pages_processed,
529
- if manifest_stats.pages_processed == 1 { "" } else { "s" },
530
- manifest_stats.pages_skipped,
531
- manifest_stats.total_time_ms
532
- );
533
- }
534
-
535
- if stats.files_copied > 0 {
536
- println!("* Copied files ({} file{}) in {:.0}ms",
537
- stats.files_copied,
538
- if stats.files_copied == 1 { "" } else { "s" },
539
- stats.copy_time_ms
540
- );
541
- }
542
-
543
- // Show node_modules handling
544
- if let Some(nm_time) = stats.node_modules_time_ms {
545
- if stats.node_modules_copied_as_is {
546
- println!("* copied node_modules in {:.0}ms", nm_time);
547
- } else if let Some(ref pkg_manager) = stats.package_manager {
548
- println!("* {} install in {:.0}ms", pkg_manager, nm_time);
549
- }
550
- }
551
-
552
- // Calculate total duration as sum of all individual operations
553
- let total_duration_ms = stats.validation_time_ms.unwrap_or(0.0)
554
- + stats.components_time_ms
555
- + stats.compile_time_ms
556
- + stats.copy_time_ms
557
- + stats.node_modules_time_ms.unwrap_or(0.0)
558
- + manifest_time_ms;
559
-
560
- println!("\n{} in {:.0}ms", "Compiled".green(), total_duration_ms);
561
- println!();
562
- }
563
- Err(e) => {
564
- eprintln!("{}: {}", "Error".red(), e);
565
- eprintln!("Fix the errors and save to retry.");
566
- println!();
567
- }
568
- }
569
-
570
- println!("{}", "Watching for changes... (Ctrl+C to stop)".cyan());
571
- println!();
572
-
573
- // Canonicalize output path for reliable comparison
574
- let canonical_output = config.output.canonicalize()
575
- .unwrap_or_else(|_| config.output.clone());
576
- // Canonical source root: dependency-graph paths are canonical, so map them to
577
- // cache keys against the same base.
578
- let canonical_source = config.source.canonicalize()
579
- .unwrap_or_else(|_| config.source.clone());
580
- // Canonical pages root: notify events carry absolute paths, so a relative
581
- // --cwd would never prefix-match without canonicalizing the base.
582
- let canonical_pages = {
583
- let pages = config.source.join(&config.pages);
584
- pages.canonicalize().unwrap_or(pages)
585
- };
586
-
587
- // Keep compiler and parser alive to reuse component cache across incremental compilations
588
- let mut watch_compiler = Compiler::new(config.clone(), false);
589
- // Carry the initial compile's warm component cache into the watcher so the
590
- // first edit is already incremental (only the edited subtree re-expands).
591
- watch_compiler.adopt_component_cache(&mut compiler);
592
- let mut parser = {
593
- use crate::parser::HtmlParser;
594
- let mut p = HtmlParser::new(config.components_path());
595
- if let Err(e) = p.load_elements() {
596
- eprintln!("{}: Failed to load parser: {}", "Error".red(), e);
597
- return Err(Box::new(e));
598
- }
599
- p
600
- };
601
-
602
- let (tx, rx) = channel();
603
- let mut debouncer = new_debouncer(Duration::from_millis(100), None, tx)?;
604
-
605
- debouncer.watcher().watch(&config.source, RecursiveMode::Recursive)?;
606
-
607
- // Hot config: package.json is the compiler's config file — a change to
608
- // any vibe-compiler option (spa, minify, paths) takes effect live via a
609
- // watcher self-restart. Watched explicitly: it can sit outside the source
610
- // tree, and skip_files (which routinely lists package.json for copying)
611
- // must not silence it. The raw baseline is compared on each event so
612
- // formatting-only writes don't restart anything.
613
- let config_file = config.working_dir.join("package.json");
614
- let canonical_config_file = config_file.canonicalize().unwrap_or_else(|_| config_file.clone());
615
- let config_baseline = Config::load(config.working_dir.clone());
616
- if config_file.exists() {
617
- debouncer.watcher().watch(&config_file, RecursiveMode::NonRecursive)?;
618
- }
619
-
620
- loop {
621
- match rx.recv() {
622
- Ok(result) => {
623
- match result {
624
- Ok(events) => {
625
- // Collect unique paths from events
626
- let mut changed_paths: HashSet<PathBuf> = HashSet::new();
627
-
628
- for event in events {
629
- for path in &event.paths {
630
- // Config file first, before any filter: a changed
631
- // vibe-compiler section restarts the watcher in
632
- // place with the fresh config.
633
- let is_config_file = path
634
- .canonicalize()
635
- .map(|p| p == canonical_config_file)
636
- .unwrap_or(*path == config_file);
637
- if is_config_file {
638
- if Config::load(config.working_dir.clone()) != config_baseline {
639
- restart_with_fresh_config(_watch_lock);
640
- }
641
- continue;
642
- }
643
-
644
- // Skip blacklisted files/directories (check entire path, not just filename)
645
- if is_path_blacklisted(path, &config.source, &config.skip_files) {
646
- continue;
647
- }
648
-
649
- if let Some(ext) = path.extension() {
650
- if ext == "html" || ext == "css" || ext == "js" {
651
- changed_paths.insert(path.clone());
652
- }
653
- }
654
- }
655
- }
656
-
657
- if changed_paths.is_empty() {
658
- continue;
659
- }
660
-
661
- // Determine what needs recompiling
662
- let mut pages_to_recompile: HashSet<PathBuf> = HashSet::new();
663
- // Component caches that go stale this batch: each edited
664
- // component plus the ancestors that inline it. Everything
665
- // else stays cached and is reused.
666
- let mut stale_component_keys: HashSet<String> = HashSet::new();
667
- // Changed component files to re-mirror into the output —
668
- // runtime-fetched components are served from that mirror,
669
- // so it must track every edit/deletion even when no page
670
- // inlines the component (zero graph dependents).
671
- let mut components_to_mirror: Vec<PathBuf> = Vec::new();
672
- // SPA mode: any pages-tree change (edit, add, delete) or a
673
- // component edit with page dependents re-runs the whole SPA
674
- // pass after this batch — fragments re-transform, the shell
675
- // recomposes, routes resync, orphans prune.
676
- let mut spa_pages_changed = false;
677
-
678
- for path in &changed_paths {
679
- // Skip files in output directory (avoid infinite loop)
680
- let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
681
- if canonical_path.starts_with(&canonical_output) {
682
- continue;
683
- }
684
-
685
- // Skip blacklisted files/directories
686
- if is_path_blacklisted(path, &config.source, &config.skip_files) {
687
- continue;
688
- }
689
-
690
- let relative_path = path.strip_prefix(&config.source)
691
- .unwrap_or(path);
692
-
693
- // SPA pages never take the MPA per-page path — the
694
- // batch-level SPA pass owns them. Deleted files can't
695
- // canonicalize; their event path is already absolute.
696
- if config.spa
697
- && path.extension().and_then(|e| e.to_str()) == Some("html")
698
- && canonical_path.starts_with(&canonical_pages)
699
- {
700
- if path.exists() {
701
- println!("{} {} changed", "[watch]".cyan(), relative_path.display());
702
- } else {
703
- println!("{} {} deleted", "[watch]".yellow(), relative_path.display());
704
- }
705
- spa_pages_changed = true;
706
- continue;
707
- }
708
-
709
- // Check if it's a component
710
- if path.starts_with(&config.source.join(&config.components)) {
711
- // Component changed - recompile all transitively dependent pages
712
- // Canonicalize to match how dependencies were stored (handles case sensitivity)
713
- let path_canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
714
-
715
- // The output's components mirror tracks every change,
716
- // dependents or not: a component nothing inlines is
717
- // still fetched from the mirror at runtime
718
- // (`<component src="@[page.src]">`, iter-prop roots).
719
- if path.exists() {
720
- println!("{} {} changed", "[watch]".cyan(), relative_path.display());
721
- } else {
722
- println!("{} {} deleted", "[watch]".yellow(), relative_path.display());
723
- }
724
- components_to_mirror.push(path.clone());
725
-
726
- // Refresh this component's own dependency edges so a
727
- // newly-added <component src> (e.g. a child file created
728
- // mid-session) is learned. Without this the new child maps
729
- // to zero dependent pages and editing it is a silent no-op.
730
- if path.exists() {
731
- if let Ok(html) = std::fs::read_to_string(path) {
732
- let components_path = config.source.join(&config.components);
733
- let deps: HashSet<PathBuf> =
734
- extract_component_dependencies(&html, &components_path)
735
- .into_iter()
736
- .map(|dep| {
737
- let dep_absolute = config.source.join(&dep);
738
- dep_absolute.canonicalize().unwrap_or(dep_absolute)
739
- })
740
- .collect();
741
- graph.refresh_file(path, deps, true);
742
- }
743
- }
744
-
745
- let mut dependent_pages = graph.get_all_dependent_pages(&path_canonical);
746
- // SPA pages re-transform through the SPA pass, not
747
- // the MPA per-page compile (graph paths are
748
- // canonical — compare against the canonical root).
749
- let mut has_spa_dependents = false;
750
- if config.spa {
751
- let (spa_pages, mpa_pages): (HashSet<_>, HashSet<_>) = dependent_pages
752
- .into_iter()
753
- .partition(|page| page.starts_with(&canonical_pages));
754
- if !spa_pages.is_empty() {
755
- spa_pages_changed = true;
756
- has_spa_dependents = true;
757
- }
758
- dependent_pages = mpa_pages;
759
- }
760
- if !dependent_pages.is_empty() || has_spa_dependents {
761
- // Invalidate only the edited component and the
762
- // components whose cached inlined content embeds it
763
- // (its ancestors). Every other component stays cached,
764
- // so each affected page re-expands just the changed
765
- // subtree instead of its whole component tree. The
766
- // SPA pass re-expands through the same cache, so
767
- // spa-dependent pages need this too.
768
- for stale in graph.get_all_dependent_components(&path_canonical) {
769
- if let Some(key) = component_cache_key(&stale, &canonical_source) {
770
- stale_component_keys.insert(key);
771
- }
772
- }
773
-
774
- // Reload the changed component in the parser's element cache
775
- // (used for custom element syntax like <Layout>)
776
- if path.exists() {
777
- let _ = parser.reload_element(path);
778
- }
779
-
780
- pages_to_recompile.extend(dependent_pages);
781
- }
782
- } else if let Some(ext) = path.extension() {
783
- if ext == "html" {
784
- // Check if file still exists (handle deletions)
785
- if !path.exists() {
786
- // File was deleted - clean up dependency graph
787
- println!("{} {} deleted", "[watch]".yellow(), relative_path.display());
788
-
789
- if let Some(old_deps) = graph.page_to_components.remove(path) {
790
- for dep in old_deps {
791
- if let Some(pages) = graph.component_to_pages.get_mut(&dep) {
792
- pages.remove(path);
793
- }
794
- }
795
- }
796
-
797
- // Delete corresponding compiled output
798
- let output_path = canonical_output.join(relative_path);
799
- if output_path.exists() {
800
- if let Err(e) = std::fs::remove_file(&output_path) {
801
- eprintln!("{}: Failed to delete {}: {}", "Warning".yellow(), output_path.display(), e);
802
- }
803
- }
804
-
805
- // Delete corresponding manifest
806
- let manifest_path = canonical_output.join("vibe-hyperspeed").join(format!("{}.manifest.js", relative_path.display()));
807
- if manifest_path.exists() {
808
- if let Err(e) = std::fs::remove_file(&manifest_path) {
809
- eprintln!("{}: Failed to delete manifest {}: {}", "Warning".yellow(), manifest_path.display(), e);
810
- }
811
- }
812
-
813
- continue;
814
- }
815
-
816
- // Page changed - recompile just this page
817
- println!("{} {} changed", "[watch]".cyan(), relative_path.display());
818
- pages_to_recompile.insert(path.clone());
819
-
820
- // Rebuild dependencies for this page (shared with the
821
- // component path via refresh_file).
822
- if let Ok(html) = std::fs::read_to_string(path) {
823
- let components_path = config.source.join(&config.components);
824
- let deps: HashSet<PathBuf> =
825
- extract_component_dependencies(&html, &components_path)
826
- .into_iter()
827
- .map(|dep| {
828
- let dep_absolute = config.source.join(&dep);
829
- dep_absolute.canonicalize().unwrap_or(dep_absolute)
830
- })
831
- .collect();
832
- graph.refresh_file(path, deps, false);
833
- }
834
- }
835
- }
836
- }
837
-
838
- // Separate HTML files (from dependency graph) and CSS/JS assets (from changed_paths)
839
- // Filter out deleted files
840
- let html_files: Vec<PathBuf> = pages_to_recompile.into_iter()
841
- .filter(|p| p.exists())
842
- .collect();
843
-
844
- let mut asset_files: Vec<PathBuf> = Vec::new();
845
- for path in &changed_paths {
846
- // Skip files in output directory
847
- let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
848
- if canonical_path.starts_with(&canonical_output) {
849
- continue;
850
- }
851
-
852
- // Skip blacklisted files/directories
853
- if is_path_blacklisted(path, &config.source, &config.skip_files) {
854
- continue;
855
- }
856
-
857
- // Handle deleted asset files
858
- if !path.exists() {
859
- if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
860
- if ext == "css" || ext == "js" {
861
- let relative_path = path.strip_prefix(&config.source).unwrap_or(path);
862
- let output_path = canonical_output.join(relative_path);
863
- if output_path.exists() {
864
- if let Err(e) = std::fs::remove_file(&output_path) {
865
- eprintln!("{}: Failed to delete {}: {}", "Warning".yellow(), output_path.display(), e);
866
- } else {
867
- println!("{} {} deleted", "[watch]".yellow(), relative_path.display());
868
- }
869
- }
870
- }
871
- }
872
- continue;
873
- }
874
-
875
- if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
876
- if ext == "css" || ext == "js" {
877
- asset_files.push(path.clone());
878
- }
879
- }
880
- }
881
-
882
- if html_files.is_empty() && asset_files.is_empty() && components_to_mirror.is_empty() && !spa_pages_changed {
883
- continue;
884
- }
885
-
886
- let start = std::time::Instant::now();
887
-
888
- // Use persistent compiler and parser (cache is preserved)
889
- let mut total_stats = super::CompileStats {
890
- files_compiled: 0,
891
- files_copied: 0,
892
- internal_components_unique: 0,
893
- external_components_unique: 0,
894
- internal_components_total: 0,
895
- external_components_total: 0,
896
- compile_time_ms: 0.0,
897
- copy_time_ms: 0.0,
898
- components_time_ms: 0.0,
899
- validation_time_ms: None,
900
- node_modules_time_ms: None,
901
- package_manager: None,
902
- node_modules_copied_as_is: false,
903
- components_as_is: config.components_as_is,
904
- };
905
-
906
- let mut manifest_stats_result = None;
907
- let mut had_errors = false;
908
-
909
- // Drop only the stale component caches (edited components +
910
- // their inlining ancestors); unchanged components are reused.
911
- // A page-only edit invalidates nothing here — its components
912
- // are still valid — so the whole cache is reused as-is. Runs
913
- // before BOTH the per-page compile and the SPA pass: either
914
- // may re-expand through this cache.
915
- let invalidated = watch_compiler.invalidate_components(&stale_component_keys);
916
- if invalidated > 0 {
917
- println!("{} {} component{} re-expanded, {} reused from cache",
918
- "↻".cyan(),
919
- invalidated,
920
- if invalidated == 1 { "" } else { "s" },
921
- watch_compiler.cached_component_count(),
922
- );
923
- }
924
-
925
- // Compile HTML files
926
- if !html_files.is_empty() {
927
- match watch_compiler.compile_specific_html_files(&html_files, &parser) {
928
- Ok(stats) => {
929
- total_stats.files_compiled = stats.files_compiled;
930
- total_stats.internal_components_total = stats.internal_components_total;
931
- total_stats.external_components_total = stats.external_components_total;
932
- total_stats.internal_components_unique = stats.internal_components_unique;
933
- total_stats.external_components_unique = stats.external_components_unique;
934
- total_stats.compile_time_ms = stats.compile_time_ms;
935
-
936
- // Generate manifests for affected HTML files
937
- if !config.runtime_as_is {
938
- match watch_compiler.generate_manifests_for_files(&html_files) {
939
- Ok(manifest_stats) => {
940
- manifest_stats_result = Some(manifest_stats);
941
- }
942
- Err(e) => {
943
- eprintln!("{}: Manifest generation failed: {}", "Warning".yellow(), e);
944
- }
945
- }
946
- }
947
- }
948
- Err(e) => {
949
- eprintln!("{}: {}", "Error".red(), e);
950
- eprintln!("Fix the errors and save to retry.");
951
- println!();
952
- had_errors = true;
953
- }
954
- }
955
- }
956
-
957
- // Copy asset files
958
- if !asset_files.is_empty() && !had_errors {
959
- match watch_compiler.copy_specific_asset_files(&asset_files) {
960
- Ok(stats) => {
961
- total_stats.files_copied = stats.files_copied;
962
- total_stats.copy_time_ms = stats.copy_time_ms;
963
- }
964
- Err(e) => {
965
- eprintln!("{}: {}", "Error".red(), e);
966
- eprintln!("Fix the errors and save to retry.");
967
- println!();
968
- had_errors = true;
969
- }
970
- }
971
- }
972
-
973
- // Keep the output's components mirror in sync — changed
974
- // components reach it even with zero dependent pages, and
975
- // deleted ones leave it (see mirror_component_files).
976
- if !components_to_mirror.is_empty() && !had_errors {
977
- match watch_compiler.mirror_component_files(&components_to_mirror) {
978
- Ok(copied) => {
979
- total_stats.files_copied += copied;
980
- }
981
- Err(e) => {
982
- eprintln!("{}: {}", "Error".red(), e);
983
- eprintln!("Fix the errors and save to retry.");
984
- println!();
985
- had_errors = true;
986
- }
987
- }
988
- }
989
-
990
- // SPA pass: re-transform fragments, recompose the shell,
991
- // resync the route table, prune orphans, refresh the
992
- // shell's manifest.
993
- if spa_pages_changed && !had_errors {
994
- match watch_compiler.compile_spa(&parser) {
995
- Ok(count) => {
996
- if !config.runtime_as_is {
997
- if let Err(e) = watch_compiler.generate_shell_manifest() {
998
- eprintln!("{}: Shell manifest failed: {}", "Warning".yellow(), e);
999
- }
1000
- }
1001
- println!("{} Recompiled SPA ({} fragment{} + shell)",
1002
- "✓".green(),
1003
- count - 1,
1004
- if count == 2 { "" } else { "s" }
1005
- );
1006
- }
1007
- Err(e) => {
1008
- eprintln!("{}: {}", "Error".red(), e);
1009
- eprintln!("Fix the errors and save to retry.");
1010
- println!();
1011
- had_errors = true;
1012
- }
1013
- }
1014
- }
1015
-
1016
- if !had_errors {
1017
- // Show what was updated
1018
- if total_stats.files_compiled > 0 {
1019
- println!("{} Compiled {} HTML file{}",
1020
- "✓".green(),
1021
- total_stats.files_compiled,
1022
- if total_stats.files_compiled == 1 { "" } else { "s" }
1023
- );
1024
- }
1025
-
1026
- if let Some(manifest_stats) = manifest_stats_result {
1027
- if manifest_stats.pages_processed > 0 {
1028
- println!("{} Generated {} manifest{}",
1029
- "✓".green(),
1030
- manifest_stats.pages_processed,
1031
- if manifest_stats.pages_processed == 1 { "" } else { "s" }
1032
- );
1033
- }
1034
- }
1035
-
1036
- if total_stats.files_copied > 0 {
1037
- println!("{} Copied {} asset file{}",
1038
- "✓".green(),
1039
- total_stats.files_copied,
1040
- if total_stats.files_copied == 1 { "" } else { "s" }
1041
- );
1042
- }
1043
-
1044
- let elapsed = start.elapsed().as_millis();
1045
- println!("{} Updated in {}ms", "✓".green(), elapsed);
1046
- }
1047
- println!();
1048
- }
1049
- Err(errors) => {
1050
- for error in errors {
1051
- eprintln!("{}: {:?}", "Watch error".red(), error);
1052
- }
1053
- }
1054
- }
1055
- }
1056
- Err(e) => {
1057
- eprintln!("{}: {}", "Channel error".red(), e);
1058
- break;
1059
- }
1060
- }
1061
- }
1062
-
1063
- Ok(())
1064
- }
1065
-
1066
- #[cfg(test)]
1067
- mod tests {
1068
- use super::*;
1069
-
1070
- // leaf <- mid <- layout <- page, with `sibling` an unrelated component that
1071
- // layout also uses. Editing `leaf` invalidates the cached, fully-inlined
1072
- // content of leaf and every component whose cache embeds it (mid, layout) —
1073
- // but NOT `sibling` (its cache never contained leaf) and NOT pages (they are
1074
- // recompiled, not cache entries).
1075
- fn chain_graph() -> (DependencyGraph, [PathBuf; 5]) {
1076
- let leaf = PathBuf::from("/src/components/AccountProgression.html");
1077
- let mid = PathBuf::from("/src/components/Sidebar.html");
1078
- let layout = PathBuf::from("/src/components/Layout.html");
1079
- let sibling = PathBuf::from("/src/components/Topbar.html");
1080
- let page = PathBuf::from("/src/pages/index.html");
1081
-
1082
- let mut graph = DependencyGraph::new();
1083
- graph.add_dependency(mid.clone(), leaf.clone(), true); // mid uses leaf
1084
- graph.add_dependency(layout.clone(), mid.clone(), true); // layout uses mid
1085
- graph.add_dependency(layout.clone(), sibling.clone(), true); // layout uses sibling
1086
- graph.add_dependency(page.clone(), layout.clone(), false); // page uses layout
1087
-
1088
- (graph, [leaf, mid, layout, sibling, page])
1089
- }
1090
-
1091
- #[test]
1092
- fn dependent_components_are_just_the_stale_ancestor_chain() {
1093
- let (graph, [leaf, mid, layout, sibling, page]) = chain_graph();
1094
-
1095
- let stale = graph.get_all_dependent_components(&leaf);
1096
-
1097
- // The edited component itself, plus every component that inlines it.
1098
- assert!(stale.contains(&leaf), "leaf itself must be invalidated");
1099
- assert!(stale.contains(&mid), "mid inlines leaf — stale");
1100
- assert!(stale.contains(&layout), "layout transitively inlines leaf — stale");
1101
- // A sibling that does not contain leaf keeps its cache.
1102
- assert!(!stale.contains(&sibling), "sibling never inlined leaf — must stay cached");
1103
- // Pages are recompiled, not component-cache entries.
1104
- assert!(!stale.contains(&page), "pages are not in the component set");
1105
- assert_eq!(stale.len(), 3, "exactly leaf + mid + layout go stale");
1106
- }
1107
-
1108
- #[test]
1109
- fn editing_a_leaf_invalidates_far_fewer_than_the_dependent_pages() {
1110
- let (graph, [leaf, _mid, _layout, _sibling, _page]) = chain_graph();
1111
-
1112
- // dependent-pages drives which output files recompile; dependent-
1113
- // components drives which caches to drop — the minimal stale set, which
1114
- // never includes unrelated siblings.
1115
- let stale_components = graph.get_all_dependent_components(&leaf);
1116
- let dependent_pages = graph.get_all_dependent_pages(&leaf);
1117
-
1118
- assert_eq!(dependent_pages.len(), 1, "one page inlines leaf");
1119
- assert!(stale_components.iter().all(|c| c.starts_with("/src/components")));
1120
- }
1121
-
1122
- // The new-file bug: a component created mid-session is referenced by editing
1123
- // an existing component (e.g. Layout adds <component src="SeasonDowntime">).
1124
- // The watcher must refresh the edited component's own deps so that edge is
1125
- // learned — otherwise editing the new component maps to zero pages and is a
1126
- // silent no-op.
1127
- #[test]
1128
- fn refreshing_a_component_learns_newly_added_child_references() {
1129
- let layout = PathBuf::from("/src/components/Layout.html");
1130
- let page = PathBuf::from("/src/pages/index.html");
1131
- let downtime = PathBuf::from("/src/components/SeasonDowntime.html");
1132
-
1133
- let mut graph = DependencyGraph::new();
1134
- graph.add_dependency(page.clone(), layout.clone(), false); // page uses layout
1135
-
1136
- // Brand-new component nobody references yet.
1137
- assert!(
1138
- graph.get_all_dependent_pages(&downtime).is_empty(),
1139
- "nothing uses the new component yet"
1140
- );
1141
-
1142
- // Layout is edited to add <component src="SeasonDowntime">. The watcher
1143
- // refreshes layout's deps, which must learn the layout -> downtime edge.
1144
- let mut new_deps = HashSet::new();
1145
- new_deps.insert(downtime.clone());
1146
- graph.refresh_file(&layout, new_deps, true);
1147
-
1148
- // Now editing the new component must map back to the page that inlines it.
1149
- assert!(
1150
- graph.get_all_dependent_pages(&downtime).contains(&page),
1151
- "after layout learns the new component, editing it must recompile the page"
1152
- );
1153
- }
1154
-
1155
- // refresh_file replaces a file's edges, so a dependency it no longer uses is
1156
- // dropped (no phantom recompiles of files that reference the removed dep).
1157
- #[test]
1158
- fn refreshing_a_file_drops_stale_dependencies() {
1159
- let page = PathBuf::from("/src/pages/index.html");
1160
- let old = PathBuf::from("/src/components/Old.html");
1161
- let new = PathBuf::from("/src/components/New.html");
1162
-
1163
- let mut graph = DependencyGraph::new();
1164
- graph.add_dependency(page.clone(), old.clone(), false);
1165
- assert!(graph.get_all_dependent_pages(&old).contains(&page));
1166
-
1167
- // Page edited: now uses `new` instead of `old`.
1168
- let mut deps = HashSet::new();
1169
- deps.insert(new.clone());
1170
- graph.refresh_file(&page, deps, false);
1171
-
1172
- assert!(
1173
- graph.get_all_dependent_pages(&new).contains(&page),
1174
- "new dependency is learned"
1175
- );
1176
- assert!(
1177
- !graph.get_all_dependent_pages(&old).contains(&page),
1178
- "stale dependency is dropped"
1179
- );
1180
- }
1181
-
1182
- // Same as above but for a component's own deps (component -> component edges),
1183
- // which previously had no forward tracking to clear.
1184
- #[test]
1185
- fn refreshing_a_component_drops_stale_child_references() {
1186
- let page = PathBuf::from("/src/pages/index.html");
1187
- let parent = PathBuf::from("/src/components/Parent.html");
1188
- let old_child = PathBuf::from("/src/components/OldChild.html");
1189
- let new_child = PathBuf::from("/src/components/NewChild.html");
1190
-
1191
- let mut graph = DependencyGraph::new();
1192
- graph.add_dependency(page.clone(), parent.clone(), false); // page uses parent
1193
- graph.add_dependency(parent.clone(), old_child.clone(), true); // parent uses old_child
1194
- assert!(graph.get_all_dependent_pages(&old_child).contains(&page));
1195
-
1196
- // Parent edited: swaps old_child for new_child.
1197
- let mut deps = HashSet::new();
1198
- deps.insert(new_child.clone());
1199
- graph.refresh_file(&parent, deps, true);
1200
-
1201
- assert!(
1202
- graph.get_all_dependent_pages(&new_child).contains(&page),
1203
- "new child reference is learned transitively"
1204
- );
1205
- assert!(
1206
- !graph.get_all_dependent_pages(&old_child).contains(&page),
1207
- "stale child reference is dropped"
1208
- );
1209
- }
1210
-
1211
- fn lock_test_output(name: &str) -> PathBuf {
1212
- let out = std::env::temp_dir().join(format!("vibe_watch_lock_{}_out", name));
1213
- let _ = std::fs::remove_dir_all(&out);
1214
- std::fs::create_dir_all(&out).unwrap();
1215
- // A previous crashed test run may have left a lock behind.
1216
- let _ = std::fs::remove_file(WatchLock::lock_path_for(&out));
1217
- out
1218
- }
1219
-
1220
- // Two concurrent watchers on one output dir double-compile every save and
1221
- // race each other's writes (torn manifest reads → blank pages). The second
1222
- // watcher must refuse to start while the first holds the lock.
1223
- #[test]
1224
- fn second_watch_lock_on_same_output_fails_while_held() {
1225
- let out = lock_test_output("same");
1226
-
1227
- let first = WatchLock::acquire(&out).expect("first lock acquires");
1228
- let second = WatchLock::acquire(&out);
1229
- let msg = second.expect_err("second watcher on the same output must fail loudly");
1230
- assert!(
1231
- msg.contains("vibe compile --watch"),
1232
- "error should explain the conflict: {msg}"
1233
- );
1234
-
1235
- drop(first);
1236
- WatchLock::acquire(&out).expect("released lock can be re-acquired");
1237
- }
1238
-
1239
- // A watcher killed without unwinding (Ctrl+C, SIGTERM from dev tooling)
1240
- // leaves its lock file behind; the pid inside is dead, so the next watcher
1241
- // steals the lock instead of being locked out forever.
1242
- #[test]
1243
- fn stale_lock_from_dead_process_is_stolen() {
1244
- let out = lock_test_output("stale");
1245
-
1246
- // No live process can have this pid (pid_max is 99998 on macOS,
1247
- // ≤ 4194304 on Linux).
1248
- std::fs::write(WatchLock::lock_path_for(&out), "4294967295").unwrap();
1249
-
1250
- WatchLock::acquire(&out).expect("stale lock from a dead process must be stolen");
1251
- }
1252
-
1253
- #[test]
1254
- fn locks_on_different_outputs_do_not_conflict() {
1255
- let out_a = lock_test_output("indep_a");
1256
- let out_b = lock_test_output("indep_b");
1257
-
1258
- let _a = WatchLock::acquire(&out_a).expect("lock a");
1259
- WatchLock::acquire(&out_b).expect("an unrelated output dir must not be blocked");
1260
- }
1261
-
1262
- #[test]
1263
- fn cache_key_is_source_relative_with_leading_slash() {
1264
- let source = PathBuf::from("/proj/src");
1265
- // Must match the key form fetch_component_recursive stores.
1266
- assert_eq!(
1267
- component_cache_key(&PathBuf::from("/proj/src/components/Sidebar.html"), &source)
1268
- .as_deref(),
1269
- Some("/components/Sidebar.html"),
1270
- );
1271
- // A path outside the source root (e.g. an external URL component's
1272
- // resolved path) yields no key, so it's never wrongly invalidated.
1273
- assert_eq!(
1274
- component_cache_key(&PathBuf::from("/elsewhere/x.html"), &source),
1275
- None,
1276
- );
1277
- }
1278
- }