aiken/crates/aiken-project/src/watch.rs

226 lines
6.5 KiB
Rust

use crate::{telemetry::Terminal, Project};
use miette::{Diagnostic, IntoDiagnostic};
use notify::{Event, RecursiveMode, Watcher};
use owo_colors::{OwoColorize, Stream::Stderr};
use std::{
collections::VecDeque,
env,
ffi::OsStr,
fmt::{self, Display},
path::Path,
sync::{Arc, Mutex},
};
#[derive(Debug, Diagnostic, thiserror::Error)]
enum ExitFailure {
#[error("")]
ExitFailure,
}
impl ExitFailure {
fn into_report() -> miette::Report {
ExitFailure::ExitFailure.into()
}
}
struct Summary {
warning_count: usize,
error_count: usize,
}
impl Display for Summary {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(&format!(
" {} {} {}, {} {}",
"Summary"
.if_supports_color(Stderr, |s| s.purple())
.if_supports_color(Stderr, |s| s.bold()),
self.error_count,
if self.error_count == 1 {
"error"
} else {
"errors"
}
.if_supports_color(Stderr, |s| s.red())
.if_supports_color(Stderr, |s| s.bold()),
self.warning_count,
if self.warning_count == 1 {
"warning"
} else {
"warnings"
}
.if_supports_color(Stderr, |s| s.yellow())
.if_supports_color(Stderr, |s| s.bold()),
))
}
}
/// A default filter for file events that catches the most relevant "source" changes
pub fn default_filter(evt: &Event) -> bool {
// Only watch for changes to .ak and aiken.toml files, and ignore the build directory
let source_file = evt
.paths
.iter()
.any(|p| p.extension() == Some(OsStr::new("ak")) || p.ends_with("aiken.toml"));
let build_dir = evt
.paths
.iter()
.all(|p| p.ancestors().any(|a| a.ends_with("build")));
match evt.kind {
notify::EventKind::Any => true,
notify::EventKind::Create(_)
| notify::EventKind::Modify(_)
| notify::EventKind::Remove(_) => source_file && !build_dir,
_ => false,
}
}
pub fn with_project<A>(directory: Option<&Path>, deny: bool, mut action: A) -> miette::Result<()>
where
A: FnMut(&mut Project<Terminal>) -> Result<(), Vec<crate::error::Error>>,
{
let project_path = if let Some(d) = directory {
d.to_path_buf()
} else {
env::current_dir().into_diagnostic()?
};
let mut project = match Project::new(project_path, Terminal) {
Ok(p) => Ok(p),
Err(e) => {
e.report();
Err(ExitFailure::into_report())
}
}?;
let build_result = action(&mut project);
let warnings = project.warnings();
let warning_count = warnings.len();
for warning in &warnings {
warning.report()
}
if let Err(errs) = build_result {
for err in &errs {
err.report()
}
eprintln!(
"{}",
Summary {
warning_count,
error_count: errs.len(),
}
);
return Err(ExitFailure::into_report());
} else {
eprintln!(
"{}",
Summary {
error_count: 0,
warning_count
}
);
}
if warning_count > 0 && deny {
Err(ExitFailure::into_report())
} else {
Ok(())
}
}
/// Run a function each time a file in the project changes
///
/// ```text
/// // Note: doctest disabled, because aiken_project doesn't have an implementation of EventListener I can use
/// use aiken_project::watch::{watch_project, default_filter};
/// use aiken_project::{Project};
/// watch_project(None, Terminal, default_filter, 500, |project| {
/// println!("Project changed!");
/// Ok(())
/// });
/// ```
pub fn watch_project<F, A>(
directory: Option<&Path>,
filter: F,
debounce: u32,
mut action: A,
) -> miette::Result<()>
where
F: Fn(&Event) -> bool,
A: FnMut(&mut Project<Terminal>) -> Result<(), Vec<crate::error::Error>>,
{
let project_path = directory
.map(|p| p.to_path_buf())
.unwrap_or(env::current_dir().into_diagnostic()?);
// Set up a queue for events, primarily so we can debounce on related events
let queue = Arc::new(Mutex::new(VecDeque::new()));
// Run the action once, to start
queue
.lock()
.expect("lock queue")
.push_back(Event::default());
// Spawn a file-watcher that will put each change event on the queue
let queue_write = queue.clone();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| {
match res {
Ok(event) => queue_write
.lock()
.expect("lock queue")
.push_back(event.clone()),
Err(e) => {
// TODO: miette diagnostic?
println!(
"Encountered an error while monitoring for file changes: {:?}",
e
)
}
};
})
.into_diagnostic()?;
// Start watching for any changes in the project directory
let _ = watcher.watch(project_path.as_path(), RecursiveMode::Recursive);
// And then start reading from the queue
let queue_read = queue.clone();
loop {
// We sleep for the debounce interval, because notify will dump 12 related events into the queue all at once
std::thread::sleep(std::time::Duration::from_millis(debounce.into()));
// Grab the lock, and pop all events except the last one off the queue
let mut queue = queue_read.lock().expect("lock queue");
let mut latest = None;
// debounce the events, and ignore build/lock changes, because they come in in large batches
while let Some(evt) = queue.pop_back() {
// check if this event is meaningful to the caller
if !filter(&evt) {
continue;
}
latest = Some(evt);
}
// release the lock here, in case other events come in
drop(queue);
// If we have an event that survived the filter, then we can construct the project and invoke the action
if latest.is_some() {
print!("{esc}c", esc = 27 as char);
println!(
"{} ...",
" Watching"
.if_supports_color(Stderr, |s| s.bold())
.if_supports_color(Stderr, |s| s.purple()),
);
with_project(directory, false, &mut action).unwrap_or(())
}
}
}