char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somdeslop(task): unify watchset + glob machinerya29d09f

main
24.9 KiB663 linesraw
1use std::collections::BTreeSet;
2use std::path::{Component, Path, PathBuf};
3
4use glob::Pattern;
5use sha2::{Digest, Sha256};
6
7use crate::{Error, Graph, Result, collect_paths, compile_pattern, covers};
8
9#[derive(Debug, Clone, Default, PartialEq)]
10pub struct WatchSet {
11    pub patterns: Vec<Pattern>,
12    /// Physically resolved env-file targets, for watching and classifying
13    /// edits to the file that is actually loaded.
14    pub env_files: Vec<PathBuf>,
15    /// The env-file paths as configured (joined to the package root, not
16    /// resolved), so replacing a symlink along the way is also seen.
17    pub env_aliases: Vec<PathBuf>,
18    /// Symlinks discovered while resolving an env file, whose
19    /// retargeting changes which file is loaded.
20    pub env_links: Vec<PathBuf>,
21    /// Missing directory components traversed while resolving, kept
22    /// even when a later `..` removes them from the final path: creating
23    /// one can make the configured spelling openable.
24    pub env_deps: Vec<PathBuf>,
25}
26
27impl Graph {
28    /// The inputs a watcher should care about for `target`: the union of
29    /// `files` patterns over its transitive dependencies, plus env files.
30    pub fn watch_set(&self, target: &str, root: &Path) -> Result<WatchSet> {
31        let mut set = WatchSet::default();
32        let mut seen = BTreeSet::new();
33        let mut ancestors: Vec<String> = Vec::new();
34        fn visit(
35            graph: &Graph,
36            name: &str,
37            root: &Path,
38            ancestors: &mut Vec<String>,
39            seen: &mut BTreeSet<String>,
40            set: &mut WatchSet,
41        ) -> Result<()> {
42            if let Some(start) = ancestors.iter().position(|a| a == name) {
43                let mut cycle = ancestors[start..].to_vec();
44                cycle.push(name.to_owned());
45                return Err(Error::Cycle(cycle.join(" -> ")));
46            }
47            if !seen.insert(name.to_owned()) {
48                return Ok(());
49            }
50            let task = graph.get(name)?;
51            ancestors.push(name.to_owned());
52            for dependency in &task.dependencies {
53                visit(graph, dependency, root, ancestors, seen, set)?;
54            }
55            ancestors.pop();
56            if let Some(patterns) = &task.files {
57                for pattern in patterns {
58                    let normalized = crate::glob::normalize_pattern(pattern);
59                    if !set
60                        .patterns
61                        .iter()
62                        .any(|known| known.as_str() == normalized)
63                    {
64                        set.patterns.push(compile_pattern(name, pattern)?);
65                    }
66                }
67            }
68            if let Some(file) = &task.env_file {
69                let alias = root.join(file);
70                if !set.env_aliases.contains(&alias) {
71                    set.env_aliases.push(alias.clone());
72                }
73                let resolution = resolve_watch_target(&alias);
74                for dep in resolution.dependencies {
75                    if !set.env_deps.contains(&dep) {
76                        set.env_deps.push(dep);
77                    }
78                }
79                for link in resolution.links {
80                    if !set.env_links.contains(&link) {
81                        set.env_links.push(link);
82                    }
83                }
84                if let Some(target) = resolution.target.or(resolution.candidate)
85                    && !set.env_files.contains(&target)
86                {
87                    set.env_files.push(target);
88                }
89            }
90            Ok(())
91        }
92        visit(self, target, root, &mut ancestors, &mut seen, &mut set)?;
93        Ok(set)
94    }
95}
96
97pub fn relative_to(root: &Path, path: &Path) -> Option<String> {
98    Some(
99        path.strip_prefix(root)
100            .ok()?
101            .components()
102            .map(|part| part.as_os_str().to_string_lossy())
103            .collect::<Vec<_>>()
104            .join("/"),
105    )
106}
107
108pub fn remember_directories(root: &Path, known_dirs: &mut BTreeSet<String>, paths: &[PathBuf]) {
109    // Fold established types into the directory history
110    // before classifying: a path now known to be a file
111    // must not borrow its former directory identity.
112    for path in paths {
113        let path = normalize_path(path);
114        if let Some(relative) = relative_to(root, &path) {
115            if path.is_dir() {
116                known_dirs.insert(relative);
117            } else if path.symlink_metadata().is_ok() {
118                known_dirs.remove(&relative);
119            }
120        }
121    }
122}
123
124pub fn forget_directories(root: &Path, known_dirs: &mut BTreeSet<String>, gone: &[PathBuf]) {
125    // Sources of removals/renames keep their historical
126    // type for this event only.
127    for path in gone {
128        if let Some(relative) = relative_to(root, &normalize_path(path)) {
129            known_dirs.remove(&relative);
130        }
131    }
132}
133
134/// Resolves `.` and `..` lexically, so config-relative spellings of the
135/// same file compare equal however they were written.
136pub fn normalize_path(path: &Path) -> PathBuf {
137    let mut normalized = PathBuf::new();
138    for component in path.components() {
139        match component {
140            Component::CurDir => {}
141            Component::ParentDir => {
142                if matches!(
143                    normalized.components().next_back(),
144                    Some(Component::Normal(_))
145                ) {
146                    normalized.pop();
147                } else if !normalized.has_root() {
148                    normalized.push("..");
149                }
150            }
151            _ => normalized.push(component.as_os_str()),
152        }
153    }
154    normalized
155}
156
157/// Resolves an env-file path to the file a watch should follow, the way
158/// opening it would: each existing component is canonicalized as it is
159/// reached, so a `..` after a symlink applies to the symlink's target.
160/// Traversal that opening would reject (through a missing or regular-file
161/// component) resolves to None instead of being silently rerouted; the
162/// configured alias is still watched, and the Runner still opens the
163/// original spelling.
164pub struct EnvResolution {
165    pub target: Option<PathBuf>,
166    /// Where the target would be if the missing directories along the
167    /// way existed; watched so their creation is noticed.
168    pub candidate: Option<PathBuf>,
169    pub links: Vec<PathBuf>,
170    pub dependencies: Vec<PathBuf>,
171}
172
173pub fn resolve_watch_target(path: &Path) -> EnvResolution {
174    resolve_watch_target_inner(path, 0)
175}
176
177fn resolve_watch_target_inner(path: &Path, depth: usize) -> EnvResolution {
178    let none = |links: Vec<PathBuf>, dependencies: Vec<PathBuf>| EnvResolution {
179        target: None,
180        candidate: None,
181        links,
182        dependencies,
183    };
184    if depth > 40 {
185        return none(Vec::new(), Vec::new());
186    }
187    let components: Vec<Component> = path.components().collect();
188    let mut current = PathBuf::new();
189    let mut links: Vec<PathBuf> = Vec::new();
190    let mut dependencies: Vec<PathBuf> = Vec::new();
191    for (index, component) in components.iter().enumerate() {
192        let last = index + 1 == components.len();
193        match component {
194            Component::CurDir => {}
195            Component::ParentDir => {
196                // The directory being traversed out of decides where
197                // the rest of the path lands: replacing it with a
198                // symlink redirects the whole spelling, so it is a
199                // recovery dependency like the missing/blocking cases.
200                if current.parent().is_some()
201                    && current.is_dir()
202                    && !dependencies.contains(&current)
203                {
204                    dependencies.push(current.clone());
205                }
206                if !current.pop() && !current.has_root() {
207                    return none(links, dependencies);
208                }
209            }
210            Component::Normal(name) => {
211                let candidate = current.join(name);
212                match candidate.symlink_metadata() {
213                    Ok(metadata) if metadata.file_type().is_symlink() => {
214                        links.push(candidate.clone());
215                        let Ok(target) = std::fs::read_link(&candidate) else {
216                            return none(links, dependencies);
217                        };
218                        let target = if target.is_absolute() {
219                            target
220                        } else {
221                            current.join(&target)
222                        };
223                        let resolved = resolve_watch_target_inner(&target, depth + 1);
224                        links.extend(resolved.links);
225                        dependencies.extend(resolved.dependencies);
226                        let Some(reached) = resolved.target.or(resolved.candidate) else {
227                            return none(links, dependencies);
228                        };
229                        if !last && reached.symlink_metadata().is_err() {
230                            dependencies.push(reached.clone());
231                        }
232                        if !last && reached.symlink_metadata().is_ok() && !reached.is_dir() {
233                            // Traversal is blocked by an existing
234                            // non-directory; replacing it can unblock
235                            // the spelling, so keep it watched.
236                            if !dependencies.contains(&reached) {
237                                dependencies.push(reached);
238                            }
239                            return none(links, dependencies);
240                        }
241                        // A not-yet-existing reached path is a candidate
242                        // prefix: keep walking so the remaining suffix
243                        // becomes part of the watched candidate.
244                        current = reached;
245                    }
246                    Ok(_) => {
247                        let Ok(canonical) = std::fs::canonicalize(&candidate) else {
248                            return none(links, dependencies);
249                        };
250                        if !last && !canonical.is_dir() {
251                            if !dependencies.contains(&canonical) {
252                                dependencies.push(canonical);
253                            }
254                            return none(links, dependencies);
255                        }
256                        current = canonical;
257                    }
258                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
259                        if last {
260                            return EnvResolution {
261                                target: Some(candidate),
262                                candidate: None,
263                                links,
264                                dependencies,
265                            };
266                        }
267                        dependencies.push(candidate.clone());
268                        let mut attempted = candidate.clone();
269                        for rest in &components[index + 1..] {
270                            match rest {
271                                Component::ParentDir => {
272                                    attempted.pop();
273                                }
274                                Component::CurDir => {}
275                                _ => attempted.push(rest.as_os_str()),
276                            }
277                        }
278                        return EnvResolution {
279                            target: None,
280                            candidate: Some(attempted),
281                            links,
282                            dependencies,
283                        };
284                    }
285                    Err(_) => return none(links, dependencies),
286                }
287            }
288            _ => current.push(component.as_os_str()),
289        }
290    }
291    EnvResolution {
292        target: Some(current),
293        candidate: None,
294        links,
295        dependencies,
296    }
297}
298
299#[derive(Debug, PartialEq)]
300pub struct WatchState {
301    fingerprint: String,
302    config: String,
303    env: Vec<String>,
304}
305
306impl WatchState {
307    /// Config or env-file contents moved, as opposed to input files: such a
308    /// change is outside some tasks' cache keys, so a rerun must bypass
309    /// the cache rather than trust it.
310    pub fn metadata_changed(&self, baseline: &Self) -> bool {
311        self.config != baseline.config || self.env != baseline.env
312    }
313
314    pub fn capture(
315        graph: &Graph,
316        root: &Path,
317        config_source: &str,
318        name: &str,
319        extra_args: &[String],
320        watch_set: &WatchSet,
321    ) -> Self {
322        let fingerprint = match crate::graph_fingerprint(graph, root, name, extra_args) {
323            Ok(fingerprint) => format!("ok:{fingerprint}"),
324            Err(error) => format!("error:{error}"),
325        };
326        let config = format!("{:x}", Sha256::digest(config_source.as_bytes()));
327        let env = watch_set
328            .env_aliases
329            .iter()
330            .map(|path| {
331                let content = match std::fs::read(path) {
332                    Ok(bytes) => format!("{:x}", Sha256::digest(&bytes)),
333                    Err(error) => format!("unreadable:{error}"),
334                };
335                format!("{}={content}", path.display())
336            })
337            .collect();
338        WatchState { fingerprint, config, env }
339    }
340}
341
342/// The nearest existing ancestor of each env file outside the package,
343/// watched non-recursively. This may be above the file's parent when the
344/// parent does not exist yet, so coverage can be discovered (and, when it
345/// changes, reconciled by the watch loop) as directories appear.
346pub fn external_roots(root: &Path, watch_set: &WatchSet, extra: &[PathBuf]) -> Vec<PathBuf> {
347    // Every existing ancestor of an external path, not just its parent:
348    // replacing a directory higher up (secrets/ in ../secrets/v/x.env)
349    // is otherwise invisible to the watch on the nearest parent, which
350    // stays attached to the moved inode. Identities are canonical, so a
351    // path that reaches the package through a symlink does not register
352    // a second, conflicting watch over the package directory itself.
353    let canonical_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
354    let mut roots = Vec::new();
355    let aliases: Vec<PathBuf> = watch_set
356        .env_aliases
357        .iter()
358        .map(|alias| normalize_path(alias))
359        .collect();
360    for path in watch_set
361        .env_files
362        .iter()
363        .chain(&aliases)
364        .chain(&watch_set.env_links)
365        .chain(&watch_set.env_deps)
366        .chain(extra.iter())
367    {
368        if path.starts_with(root) {
369            continue;
370        }
371        for ancestor in path.ancestors().skip(1) {
372            if ancestor == root {
373                continue;
374            }
375            let Ok(canonical) = std::fs::canonicalize(ancestor) else {
376                continue;
377            };
378            if canonical == canonical_root || canonical.starts_with(&canonical_root) {
379                continue;
380            }
381            if !roots.contains(&canonical) {
382                roots.push(canonical);
383            }
384        }
385    }
386    roots
387}
388
389/// Directories currently under the package root, remembered so a
390/// directory that later vanishes (moves out, is removed) is still
391/// recognized as a directory event for the patterns it could feed.
392pub fn known_directories(root: &Path) -> BTreeSet<String> {
393    fn walk(directory: &Path, prefix: &str, dirs: &mut BTreeSet<String>) {
394        let Ok(entries) = std::fs::read_dir(directory) else {
395            return;
396        };
397        for entry in entries.filter_map(std::result::Result::ok) {
398            if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
399                continue;
400            }
401            let name = entry.file_name().to_string_lossy().into_owned();
402            let relative = if prefix.is_empty() {
403                name
404            } else {
405                format!("{prefix}/{name}")
406            };
407            dirs.insert(relative.clone());
408            walk(&entry.path(), &relative, dirs);
409        }
410    }
411    let mut dirs = BTreeSet::new();
412    walk(root, "", &mut dirs);
413    dirs
414}
415
416/// Resolved targets and intermediate links of matched input files that
417/// are themselves symlinks out of (or within) the package.
418pub fn resolved_input_paths(root: &Path, watch_set: &WatchSet) -> Vec<PathBuf> {
419    let mut paths = Vec::new();
420    for relative in collect_paths(root, false) {
421        let matched = watch_set
422            .patterns
423            .iter()
424            .any(|pattern| covers(pattern, &relative));
425        if !matched {
426            continue;
427        }
428        let absolute = root.join(&relative);
429        if absolute
430            .symlink_metadata()
431            .is_ok_and(|metadata| metadata.file_type().is_symlink())
432        {
433            for path in resolution_paths(&absolute) {
434                if !paths.contains(&path) {
435                    paths.push(path);
436                }
437            }
438        }
439    }
440    paths
441}
442
443/// The paths a symlinked file resolves through and to, watched so the
444/// target's edits are seen even when it lives outside the package.
445pub fn resolution_paths(path: &Path) -> Vec<PathBuf> {
446    let resolution = resolve_watch_target(path);
447    let mut paths = resolution.links;
448    for dep in resolution.dependencies {
449        if !paths.contains(&dep) {
450            paths.push(dep);
451        }
452    }
453    if let Some(target) = resolution.target.or(resolution.candidate)
454        && !paths.contains(&target)
455    {
456        paths.push(target);
457    }
458    paths
459}
460
461/// What an event asks of the watch loop: re-evaluate content, and/or
462/// repair the watcher installation (topology changed under it). These
463/// are independent: an input ancestor being replaced needs both.
464#[derive(Debug, Clone, Copy, Default)]
465pub struct Relevance {
466    pub evaluate: bool,
467    pub rewatch: bool,
468}
469
470/// Coverage identities used by static-config watch classification.
471pub struct WatchContext<'a> {
472    pub root: &'a Path,
473    pub config_path: &'a Path,
474    pub config_paths: &'a [PathBuf],
475    pub input_paths: &'a [PathBuf],
476    pub known_dirs: &'a BTreeSet<String>,
477}
478
479/// Filesystem event facts, independent of a particular watcher backend.
480pub struct WatchEvent<'a> {
481    pub paths: &'a [PathBuf],
482    pub structural: bool,
483    pub has_directory: bool,
484    pub is_folder_event: bool,
485}
486
487impl WatchSet {
488    pub fn classify(&self, context: WatchContext<'_>, event: WatchEvent<'_>) -> Option<Relevance> {
489        let WatchContext {
490            root,
491            config_path,
492            config_paths,
493            input_paths,
494            known_dirs,
495        } = context;
496        let structural = event.structural;
497        // Prefix matching below is deliberately conservative and is reserved
498        // for structural changes: an ordinary write to an unrelated file
499        // under a pattern's tree must not postpone the debounce forever.
500        let mut relevance: Option<Relevance> = None;
501        let merge = |relevance: &mut Option<Relevance>, evaluate: bool, rewatch: bool| {
502            let entry = relevance.get_or_insert_default();
503            entry.evaluate |= evaluate;
504            entry.rewatch |= rewatch;
505        };
506        for path in event.paths {
507            let path = normalize_path(path);
508            if path == config_path
509                || self.env_files.contains(&path)
510                || self.env_links.contains(&path)
511                || self.env_deps.contains(&path)
512                || self
513                    .env_aliases
514                    .iter()
515                    .any(|alias| normalize_path(alias) == path)
516            {
517                merge(&mut relevance, true, true);
518            }
519            if config_paths.contains(&path)
520                || config_paths.iter().any(|config| config.starts_with(&path))
521            {
522                merge(&mut relevance, true, true);
523            }
524            if input_paths.contains(&path)
525                || input_paths.iter().any(|input| input.starts_with(&path))
526            {
527                // A structural change above a resolved input needs its
528                // coverage rebuilt as well as its content re-read.
529                merge(&mut relevance, true, structural);
530            }
531            if config_path.starts_with(&path)
532                || self
533                    .env_files
534                    .iter()
535                    .chain(&self.env_links)
536                    .chain(&self.env_deps)
537                    .any(|env| env.starts_with(&path))
538                || self.env_aliases.iter().any(|alias| {
539                    alias.starts_with(&path) || normalize_path(alias).starts_with(&path)
540                })
541            {
542                merge(&mut relevance, true, true);
543            }
544            let Some(relative) = relative_to(root, &path) else {
545                continue;
546            };
547            if relative.is_empty() {
548                merge(&mut relevance, true, false);
549                continue;
550            }
551            if self
552                .patterns
553                .iter()
554                .any(|pattern| covers(pattern, &relative))
555                || ((path.is_dir()
556                    || event.has_directory
557                    || known_dirs.contains(&relative)
558                    || event.is_folder_event
559                    || (structural
560                        && self.patterns.iter().any(|pattern| {
561                            relative_is_above_literal(pattern.as_str(), &relative)
562                        })))
563                    && self
564                        .patterns
565                        .iter()
566                        .any(|pattern| pattern_could_match(pattern, &relative)))
567            {
568                merge(&mut relevance, true, false);
569            }
570        }
571        relevance
572    }
573}
574
575fn literal_prefix(pattern: &str) -> impl Iterator<Item = &str> {
576    pattern
577        .split('/')
578        .take_while(|segment| !segment.contains(['*', '?', '[']))
579}
580
581fn pattern_could_match(pattern: &Pattern, relative: &str) -> bool {
582    if covers(pattern, relative) {
583        return true;
584    }
585    let literal: Vec<&str> = literal_prefix(pattern.as_str()).collect();
586    let relative_parts: Vec<&str> = relative.split('/').collect();
587    let is_prefix = |parts: &[&str], prefix: &[&str]| {
588        prefix.len() <= parts.len() && parts[..prefix.len()] == *prefix
589    };
590    is_prefix(&literal, &relative_parts) || is_prefix(&relative_parts, &literal)
591}
592
593/// Whether `relative` is a pattern's literal prefix or a directory above
594/// it: moving or removing that directory carries the pattern's tree.
595fn relative_is_above_literal(normalized: &str, relative: &str) -> bool {
596    let literal: Vec<&str> = literal_prefix(normalized).collect();
597    let relative_parts: Vec<&str> = relative.split('/').collect();
598    relative_parts.len() <= literal.len() && literal[..relative_parts.len()] == relative_parts[..]
599}
600
601impl WatchSet {
602    /// Callback graphs may discover inputs not named by the current graph.
603    pub fn is_relevant_to_callback(&self, root: &Path, path: &Path, structural: bool) -> bool {
604        let path = normalize_path(path);
605        if self
606            .env_files
607            .iter()
608            .chain(&self.env_links)
609            .chain(&self.env_deps)
610            .any(|env| *env == path || (structural && env.starts_with(&path)))
611            || self.env_aliases.iter().any(|alias| {
612                let alias = normalize_path(alias);
613                alias == path || (structural && alias.starts_with(&path))
614            })
615        {
616            return true;
617        }
618        let Some(relative) = relative_to(root, &path) else {
619            return false;
620        };
621        if self
622            .patterns
623            .iter()
624            .any(|pattern| covers(pattern, &relative))
625        {
626            return true;
627        }
628        // A created, removed, or renamed entry cannot match the current
629        // graph's patterns if the graph does not know about it yet, but it
630        // can change what a re-evaluated graph enumerates. Treat structural
631        // events in or above a directory that holds inputs as relevant.
632        if structural {
633            let relative_dir = match relative.rsplit_once('/') {
634                Some((dir, _)) => dir.to_owned(),
635                None => String::new(),
636            };
637            return self
638                .patterns
639                .iter()
640                .map(|pattern| literal_dir(pattern.as_str()))
641                .any(|dir| {
642                    dir.is_empty()
643                        || relative.is_empty()
644                        || relative == dir
645                        || dir.starts_with(&format!("{relative}/"))
646                        || relative_dir == dir
647                        || relative_dir.starts_with(&format!("{dir}/"))
648                        || dir.starts_with(&format!("{relative_dir}/"))
649                });
650        }
651        false
652    }
653}
654
655/// The directory a pattern's literal prefix names: everything before the
656/// first glob segment, minus a trailing literal file segment.
657fn literal_dir(pattern: &str) -> String {
658    let mut literal: Vec<_> = literal_prefix(pattern).collect();
659    if literal.len() == pattern.split('/').count() && literal.len() > 1 {
660        literal.pop();
661    }
662    literal.join("/")
663}