@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.
- package/README.md +14 -4
- package/boot.js +4 -4
- package/component.js +27 -29
- package/hot-module-refresh.js +4 -4
- package/index.js +10 -15
- package/llms.txt +8 -6
- package/package.json +19 -14
- package/runtime/affected.js +159 -36
- package/runtime/cleanup.js +45 -1
- package/runtime/component.js +312 -99
- package/runtime/conditionals.js +111 -14
- package/runtime/debug.js +24 -0
- package/runtime/dispatch.js +172 -0
- package/runtime/hydrate.js +251 -111
- package/runtime/index.js +180 -71
- package/runtime/iterate.js +125 -50
- package/runtime/iteration-utils.js +59 -8
- package/runtime/manifest.js +77 -2
- package/runtime/parse.js +69 -5
- package/runtime/pre-compiled-iterations.js +19 -6
- package/runtime/pre-compiled-manifest.js +13 -4
- package/runtime/staging.js +153 -0
- package/runtime/state.js +31 -0
- package/runtime/tracking.js +173 -0
- package/runtime/utils.js +155 -78
- package/spa.js +77 -14
- package/vibe.css +8 -4
- package/CHANGELOG.md +0 -1196
- package/ROADMAP.md +0 -397
- package/compiler/bin/vibe-compile.js +0 -121
- package/compiler/native/.gitkeep +0 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +0 -2023
- package/compiler/src/Cargo.toml +0 -38
- package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
- package/compiler/src/compiler/binding_case.rs +0 -88
- package/compiler/src/compiler/compile.rs +0 -2880
- package/compiler/src/compiler/component_tagger.rs +0 -469
- package/compiler/src/compiler/iteration_optimizer.rs +0 -455
- package/compiler/src/compiler/js_analyzer.rs +0 -715
- package/compiler/src/compiler/manifest_builder.rs +0 -693
- package/compiler/src/compiler/mod.rs +0 -16
- package/compiler/src/compiler/name_binding_protect.rs +0 -207
- package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
- package/compiler/src/compiler/spa.rs +0 -477
- package/compiler/src/compiler/state_extractor.rs +0 -263
- package/compiler/src/compiler/value_stamper.rs +0 -921
- package/compiler/src/compiler/watcher.rs +0 -1278
- package/compiler/src/config.rs +0 -279
- package/compiler/src/main.rs +0 -358
- package/compiler/src/parser/element.rs +0 -96
- package/compiler/src/parser/html.rs +0 -1004
- package/compiler/src/parser/mod.rs +0 -8
- package/runtime/pre-compiled-manifest.test.mjs +0 -58
- package/runtime/scope.js +0 -50
- 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
|
-
}
|