use crate::build_command::build_command;
use notify::EventKind;
use notify::RecursiveMode;
use notify::Watcher;
use notify::event::ModifyKind;
use std::path::Path;
use std::path::PathBuf;
use std::sync::mpsc;
pub(crate) fn watch_command(args: &[String]) -> Result<(), String> {
let input = args.iter().find(|a| !a.starts_with('-')).ok_or_else(|| {
"error: no input file\n\n`voltaic watch` takes a .sdx file or a directory.".to_string()
})?;
let target = Target::resolve(Path::new(input))?;
let plain = args.iter().any(|__rdx_1| __rdx_1.eq(&"--plain"));
report(&target, plain);
let channel = mpsc::channel();
let tx = channel.0;
let rx = channel.1;
let mut watcher = notify::recommended_watcher(move |event| drop(tx.send(event)))
.map_err(|e| format!("error: cannot start the file watcher: {}", e))?;
let dir_shown = target.dir.display().to_string();
let watched = watcher.watch(&target.dir, RecursiveMode::Recursive);
watched.map_err(|e| format!("error: cannot watch {}: {}", dir_shown, e))?;
eprintln!("watching {} — press ctrl-c to stop", dir_shown);
for received in rx {
match received {
Ok(event) => {
if is_change(&event.kind) && event.paths.iter().any(|p| is_relevant(p)) {
report(&target, plain);
}
}
Err(e) => eprintln!("error: the file watcher reported: {}", e),
};
}
Ok(())
}
pub(crate) fn is_change(kind: &EventKind) -> bool {
match kind {
EventKind::Create(_) | EventKind::Remove(_) => true,
EventKind::Any | EventKind::Other => true,
EventKind::Modify(inner) => match inner {
ModifyKind::Data(_) | ModifyKind::Name(_) => true,
_ => false,
},
EventKind::Access(_) => false,
}
}
pub(crate) fn is_relevant(path: &Path) -> bool {
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else {
return false;
};
if name == "4913"
|| name.ends_with('~')
|| name.starts_with(".#")
|| name.starts_with('#') && name.ends_with('#')
{
return false;
}
let extension = path.extension().map(|e| e.to_string_lossy().to_string());
match extension.as_deref() {
Some("sdx") => !name.starts_with('.'),
_ => false,
}
}
pub(crate) fn report(target: &Target, plain: bool) {
match &target.file {
Some(file) => {
let shown = file.display().to_string();
run_build(&shown, plain)
}
None => {
for source in sources(&target.dir) {
run_build(&source, plain);
}
}
};
();
}
pub(crate) fn run_build(input: &str, plain: bool) {
let mut args = vec![input.to_string()];
if plain {
args.push("--plain".to_string());
}
match build_command(&args) {
Ok(_) => eprintln!("built {}", input),
Err(message) => eprintln!("{}", message),
};
();
}
pub(crate) fn sources(dir: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut found: Vec<String> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.is_file() && p.extension().is_some_and(|x| x == "sdx"))
.map(|p| p.display().to_string())
.collect();
found.sort();
found
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Target {
pub(crate) dir: PathBuf,
pub(crate) file: Option<PathBuf>,
}
impl Target {
pub(crate) fn resolve(arg: &Path) -> Result<Self, String> {
if arg.is_dir() {
return Ok(Target {
dir: arg.to_path_buf(),
file: None,
});
}
if !arg.exists() {
let shown = arg.display().to_string();
return Err(format!(
"error: {} does not exist\n\n`voltaic watch` takes a .sdx file or a directory of them.",
shown
));
}
let parent = arg.parent().filter(|p| !p.as_os_str().is_empty());
let dir = parent.unwrap_or(Path::new(".")).to_path_buf();
Ok(Target {
dir,
file: Some(arg.to_path_buf()),
})
}
}
#[cfg(test)]
#[repr(transparent)]
struct Dir(PathBuf);
#[cfg(test)]
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
#[cfg(test)]
impl Dir {
fn new(tag: &str) -> Self {
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let pid = std::process::id();
let dir = std::env::temp_dir().join(format!("voltaic-target-{}-{}-{}", tag, pid, n));
std::fs::create_dir_all(&dir).expect("should create");
Dir(dir)
}
}
#[cfg(test)]
impl Drop for Dir {
fn drop(&mut self) {
let _dropped = std::fs::remove_dir_all(&self.0).ok();
();
}
}
#[cfg(test)]
mod __rdx_tests {
use super::*;
#[test]
fn reading_a_file_is_not_a_change() {
assert!(!is_change(&EventKind::Access(
notify::event::AccessKind::Open(notify::event::AccessMode::Any)
)));
assert!(!is_change(&EventKind::Access(
notify::event::AccessKind::Read
)));
assert!(!is_change(&EventKind::Access(
notify::event::AccessKind::Close(notify::event::AccessMode::Read)
)));
assert!(!is_change(&EventKind::Access(
notify::event::AccessKind::Any
)));
}
#[test]
fn writing_data_is_a_change() {
assert!(is_change(&EventKind::Modify(ModifyKind::Data(
notify::event::DataChange::Any
))));
assert!(is_change(&EventKind::Modify(ModifyKind::Data(
notify::event::DataChange::Content
))));
}
#[test]
fn renaming_is_a_change() {
assert!(is_change(&EventKind::Modify(ModifyKind::Name(
notify::event::RenameMode::To
))));
assert!(is_change(&EventKind::Modify(ModifyKind::Name(
notify::event::RenameMode::Both
))));
}
#[test]
fn creating_and_removing_are_changes() {
assert!(is_change(&EventKind::Create(
notify::event::CreateKind::File
)));
assert!(is_change(&EventKind::Remove(
notify::event::RemoveKind::File
)));
}
#[test]
fn metadata_alone_is_not_a_change() {
assert!(!is_change(&EventKind::Modify(ModifyKind::Metadata(
notify::event::MetadataKind::Any
))));
assert!(!is_change(&EventKind::Modify(ModifyKind::Metadata(
notify::event::MetadataKind::AccessTime
))));
}
#[test]
fn an_unknown_kind_is_treated_as_a_change() {
assert!(is_change(&EventKind::Any));
assert!(is_change(&EventKind::Other));
}
#[test]
fn a_sdx_file_is_relevant() {
assert!(is_relevant(Path::new("src/main.sdx")));
assert!(is_relevant(Path::new("main.sdx")));
}
#[test]
fn another_extension_is_not() {
for name in ["main.rs", "main.xrs", "Cargo.toml", "README.md", "main"] {
assert!(!is_relevant(Path::new(name)), "{}", name);
}
}
#[test]
fn the_generated_output_is_not_relevant() {
assert!(!is_relevant(Path::new("main.xrs")));
assert!(!is_relevant(Path::new("main.xrs.map")));
}
#[test]
fn editor_temporaries_are_ignored() {
for name in [
"4913",
"main.sdx~",
".main.sdx.swp",
".#main.sdx",
"#main.sdx#",
".main.sdx",
] {
assert!(!is_relevant(Path::new(name)), "{} should be ignored", name);
}
}
#[test]
fn a_path_with_no_file_name_is_not_relevant() {
assert!(!is_relevant(Path::new("/")));
assert!(!is_relevant(Path::new("..")));
}
#[test]
fn an_unfamiliar_name_still_compiles_if_it_is_a_sdx() {
assert!(is_relevant(Path::new("weird-editor-artifact.sdx")));
}
#[test]
fn finds_and_sorts_sdx_files() {
let pid = std::process::id();
let dir = std::env::temp_dir().join(format!("voltaic-watch-src-{}", pid));
std::fs::create_dir_all(&dir).expect("should create");
for name in ["b.sdx", "a.sdx", "ignored.rs"] {
std::fs::write(
dir.join(name),
"main : ()\nmain =\n println! \\-> \"#{1}\"\n",
)
.expect("should write");
}
let found = sources(&dir);
assert_eq!(found.len(), 2, "{:?}", found);
assert!(
found[0].ends_with("a.sdx") && found[1].ends_with("b.sdx"),
"{:?}",
found
);
let _dropped = std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_unreadable_directory_yields_nothing() {
let found = sources(std::path::Path::new("/nonexistent/voltaic"));
assert!(found.is_empty());
}
#[test]
fn a_directory_is_watched_whole() {
let dir = Dir::new("whole");
let target = Target::resolve(&dir.0).expect("should resolve");
assert_eq!(target.dir, dir.0);
assert_eq!(target.file, None, "a directory compiles all of itself");
}
#[test]
fn a_file_is_watched_through_its_parent() {
let dir = Dir::new("parent");
let file = dir.0.join("main.sdx");
std::fs::write(&file, "main : ()\nmain =\n println! \\-> \"#{1}\"\n").expect("should write");
let target = Target::resolve(&file).expect("should resolve");
assert_eq!(target.dir, dir.0, "the parent directory is what is watched");
assert_eq!(target.file, Some(file));
}
#[test]
fn a_missing_path_is_an_error_that_says_what_is_accepted() {
let err = Target::resolve(Path::new("/nonexistent/main.sdx")).expect_err("should fail");
assert!(err.contains("does not exist"), "{}", err);
assert!(err.contains(".sdx file or a directory"), "{}", err);
}
#[test]
fn a_bare_filename_resolves_to_the_current_directory() {
let dir = Dir::new("bare");
let file = dir.0.join("main.sdx");
std::fs::write(&file, "main : ()\nmain =\n println! \\-> \"#{1}\"\n").expect("should write");
let previous = std::env::current_dir().expect("a cwd");
std::env::set_current_dir(&dir.0).expect("should chdir");
let target = Target::resolve(Path::new("main.sdx")).expect("should resolve");
std::env::set_current_dir(previous).expect("should restore");
assert_eq!(target.dir, Path::new("."), "not the empty path");
}
}