Skip to main content

alba_engine/watch/
mod.rs

1//! Watch mode: run the target, then re-run it whenever the files its
2//! subgraph declares as `inputs` change.
3//!
4//! What lives here is the vocabulary of watching — the [`Watcher`] stream
5//! and the batches it delivers, the ways a session can end, the trouble it
6//! survives, and where a session's paths are rooted and how they are shown.
7//! The loop itself lives in [`crate::session`], of which [`watch`] is one
8//! configuration: always watching, driven by nobody.
9
10mod notify;
11pub(crate) mod set;
12
13use std::path::{Path, PathBuf};
14
15use alba_core::{Project, SourceMap};
16use tokio::sync::mpsc::UnboundedSender;
17use tokio_util::sync::CancellationToken;
18
19use crate::EngineError;
20use crate::affected::Selection;
21use crate::event::RunEvent;
22use crate::scheduler::{Executors, RunOptions};
23use crate::session::session;
24pub use notify::NotifyWatcher;
25
26/// One delivery from a file watcher.
27pub enum WatchBatch {
28    /// The paths the debouncer coalesced into this batch.
29    Paths(Vec<PathBuf>),
30    /// The watcher lost track (queue overflow): something changed, but
31    /// it cannot say what. Treated as a trigger with no named paths —
32    /// the cache absorbs the imprecision.
33    Rescan,
34}
35
36/// A stream of debounced change batches. The indirection exists for the
37/// engine's own tests, which script batches instead of touching a real
38/// file system.
39#[async_trait::async_trait]
40pub trait Watcher: Send {
41    /// The next batch; `None` when the watcher died for good.
42    ///
43    /// Must be cancel-safe: the session polls it in a `select!` against
44    /// the running build and drops the future when the run finishes
45    /// first, so a batch that was already taken from the underlying
46    /// stream would be lost with it.
47    async fn next_batch(&mut self) -> Option<WatchBatch>;
48}
49
50/// Why a session ended. Sessions have no failure exit — mid-session
51/// trouble is reported and survived — so this is the complete list.
52pub enum WatchExit {
53    /// The caller's token fired: the user is done.
54    Interrupted,
55    /// The watcher's stream ended. The session cannot honestly continue:
56    /// idling while watching nothing would look exactly like a healthy
57    /// quiet session.
58    WatcherClosed,
59}
60
61/// Mid-session trouble, handed to the caller's `on_error` for rendering.
62///
63/// Both variants carry the sources their spans index, because a session
64/// outlives the project it started on: by the time an error is reported,
65/// any number of reloads may have replaced the map the caller was holding
66/// when it started the session. Rendering against that stale map would
67/// draw the caret on text the error was never about.
68pub enum SessionError {
69    /// A Beamfile stopped loading. [`alba_core::LoadError`] already pairs
70    /// the failure with every file read before it happened.
71    Load(alba_core::LoadError),
72    /// A run could not be carried out: an unknown target, or a beam the
73    /// scheduler refuses. Reported rather than fatal — the fix is one
74    /// Beamfile save away.
75    Run {
76        error: EngineError,
77        /// The session's sources as of this failure. An
78        /// [`EngineError::Core`] carries a span and a
79        /// [`alba_core::SourceId`] that only this map resolves — the
80        /// scheduler stamps each one with the id of the file the offending
81        /// beam was declared in, precisely so the caret lands there.
82        sources: SourceMap,
83    },
84}
85
86/// Runs `selection` and keeps re-running it as long as `watcher` reports
87/// relevant changes, until `cancel` fires or the watcher dies.
88///
89/// A [`crate::session`] nobody drives: watching is on for its whole life,
90/// and no command channel can retarget or stop it. Everything this
91/// function's behaviour amounts to is documented there.
92#[allow(clippy::too_many_arguments)]
93pub async fn watch(
94    beamfile: &Path,
95    project: Project,
96    sources: SourceMap,
97    selection: Selection,
98    options: RunOptions,
99    executors: Executors,
100    events: UnboundedSender<RunEvent>,
101    cancel: CancellationToken,
102    watcher: Box<dyn Watcher>,
103    render_error: &mut (dyn FnMut(&SessionError) -> String + Send),
104) -> WatchExit {
105    session(
106        beamfile,
107        project,
108        sources,
109        selection,
110        options,
111        executors,
112        events,
113        None,
114        true,
115        cancel,
116        watcher,
117        render_error,
118    )
119    .await
120}
121
122/// The absolute directory holding `beamfile`: the project root for the
123/// root Beamfile, and its own directory for an imported one.
124///
125/// An empty parent means the current directory, and must be spelled that
126/// way rather than passed on: a bare `Beamfile` (what `alba run` resolves
127/// to without `--file`) has one, `notify` rejects an empty path outright,
128/// and `strip_prefix("")` succeeds on any path at all — so an empty root
129/// would leave every displayed path absolute with nothing to signal it.
130/// The one rule, applied both to the roots put under watch and to the root
131/// that paths are displayed against, so the two cannot drift apart.
132///
133/// Lexical: `std::path::absolute` never requires the directory to exist,
134/// and never resolves symlinks — settling those is [`relative_to`]'s job,
135/// on the paths a watcher actually reports.
136pub fn beamfile_dir(beamfile: &Path) -> PathBuf {
137    let dir = beamfile
138        .parent()
139        .filter(|parent| !parent.as_os_str().is_empty())
140        .unwrap_or_else(|| Path::new("."));
141    std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf())
142}
143
144/// Root-relative display strings with `/` separators, deduplicated,
145/// sorted for stable output. A path outside the root (an import's input
146/// in a sibling directory) displays as-is.
147pub(crate) fn display_paths(root: &Path, paths: &[PathBuf]) -> Vec<String> {
148    let canonical_root = root.canonicalize();
149    let mut display: Vec<String> = paths
150        .iter()
151        .map(|path| {
152            relative_to(root, canonical_root.as_deref().ok(), path)
153                .to_string_lossy()
154                .replace('\\', "/")
155        })
156        .collect();
157    display.sort();
158    display.dedup();
159    display
160}
161
162/// `path` seen from the project root, or `path` itself when it lies
163/// outside it.
164///
165/// The paths are compared as they came before anything is resolved,
166/// because `canonicalize` is `realpath(3)` and fails outright on a file
167/// that no longer exists — and a deletion (an `rm`, a `git checkout`, the
168/// first half of a rename) is an ordinary watch event, whose path
169/// `WatchSet` still classifies as an input from its snapshot. Resolving
170/// only settles a disagreement about symlinks between what the watcher
171/// reports and how the root was spelled: the root's canonical form works
172/// on a deleted file too, its own does not and is the last attempt.
173fn relative_to(root: &Path, canonical_root: Option<&Path>, path: &Path) -> PathBuf {
174    if let Ok(relative) = path.strip_prefix(root) {
175        return relative.to_path_buf();
176    }
177    if let Some(base) = canonical_root
178        && let Ok(relative) = path.strip_prefix(base)
179    {
180        return relative.to_path_buf();
181    }
182    if let Some(base) = canonical_root
183        && let Ok(canonical) = path.canonicalize()
184        && let Ok(relative) = canonical.strip_prefix(base)
185    {
186        return relative.to_path_buf();
187    }
188    path.to_path_buf()
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    /// The nominal invocation: `alba run --watch` without `--file` resolves
196    /// to a bare `Beamfile`, and the paths a watcher reports are absolute.
197    /// Rooting the session anywhere else — the empty path, most of all —
198    /// leaves those paths absolute in the status line and in the
199    /// `watch_triggered` event, which both promise root-relative ones.
200    #[test]
201    fn a_bare_beamfile_roots_the_session_at_the_current_directory() {
202        let reported = std::env::current_dir().unwrap().join("src/input.txt");
203
204        let root = beamfile_dir(Path::new("Beamfile"));
205
206        assert_eq!(display_paths(&root, &[reported]), vec!["src/input.txt"]);
207    }
208
209    /// A Beamfile named through a directory roots the session there, not in
210    /// the directory Alba happens to have been started from.
211    #[test]
212    fn a_named_beamfile_roots_the_session_at_its_own_directory() {
213        let dir = tempfile::tempdir().unwrap();
214        let reported = dir.path().join("src/input.txt");
215
216        let root = beamfile_dir(&dir.path().join("Beamfile"));
217
218        assert_eq!(display_paths(&root, &[reported]), vec!["src/input.txt"]);
219    }
220
221    /// The rule the root computation exists to satisfy: an empty root
222    /// shortens nothing, because `strip_prefix("")` succeeds and hands back
223    /// the whole path. Every branch below it is unreachable once the first
224    /// one matches, so a session rooted at the empty path displays absolute
225    /// paths with no error anywhere to say so.
226    #[test]
227    fn an_empty_root_shortens_nothing() {
228        let path = Path::new("/project/src/input.txt");
229
230        assert_eq!(relative_to(Path::new(""), None, path), path);
231    }
232
233    /// A relative root works lexically against a path spelled the same way
234    /// — no filesystem access, nothing to resolve.
235    #[test]
236    fn a_relative_root_shortens_a_path_spelled_from_it() {
237        assert_eq!(
238            relative_to(
239                Path::new("project"),
240                None,
241                Path::new("project/src/input.txt")
242            ),
243            Path::new("src/input.txt")
244        );
245    }
246
247    /// An input reached through an import in a sibling tree is not under
248    /// the root, and is shown as it came rather than as a `../..` chain.
249    #[test]
250    fn a_path_outside_the_root_is_left_as_it_came() {
251        let root = tempfile::tempdir().unwrap();
252        let elsewhere = tempfile::tempdir().unwrap();
253        let path = elsewhere.path().join("shared/input.txt");
254
255        assert_eq!(
256            relative_to(
257                root.path(),
258                root.path().canonicalize().ok().as_deref(),
259                &path
260            ),
261            path
262        );
263    }
264
265    /// A deletion is an ordinary watch event, and `canonicalize` fails on a
266    /// path that no longer exists — so shortening must not depend on it.
267    #[test]
268    fn a_path_that_no_longer_exists_is_still_shortened() {
269        let root = tempfile::tempdir().unwrap();
270        let deleted = root.path().join("src/deleted.txt");
271        assert!(!deleted.exists(), "precondition");
272
273        assert_eq!(
274            relative_to(
275                root.path(),
276                root.path().canonicalize().ok().as_deref(),
277                &deleted
278            ),
279            Path::new("src/deleted.txt")
280        );
281    }
282
283    /// The root's canonical form settles a disagreement about symlinks
284    /// between how the root was spelled and what the watcher reports
285    /// (macOS reports `/private/var/...` for `/var/...`).
286    #[test]
287    fn a_root_spelled_through_a_symlink_still_shortens_what_the_watcher_reports() {
288        let root = tempfile::tempdir().unwrap();
289        let canonical = root.path().canonicalize().unwrap();
290        let reported = canonical.join("src/input.txt");
291
292        assert_eq!(
293            relative_to(root.path(), Some(&canonical), &reported),
294            Path::new("src/input.txt")
295        );
296    }
297}