voltaic-cli 0.2.0

The Voltaic compiler: .vlt in, .vrs containing ordinary Rust out.
import crate::build_command::build_command
import notify::EventKind
import notify::RecursiveMode
import notify::Watcher
import notify::event::ModifyKind
import std::path::Path
import std::path::PathBuf
import std::sync::mpsc

export(crate) watch_command : &[String] -> Result<(), String>
watch_command args =
  input = (.ok_or_else (\-> .to_string "error: no input file\n\n`voltaic watch` takes a .vlt file or a directory.") (.find (\a -> !.starts_with '-' a) (.iter args)))?
  target = (Target::resolve (Path::new input))?
  plain = .any (== "--plain") (.iter args)

  report (&target) plain

  channel = mpsc::channel ()
  tx = channel.0
  rx = channel.1
  mut watcher = (.map_err (\e -> format! \-> "error: cannot start the file watcher: #{e}") (notify::recommended_watcher (move \event -> drop (.send event tx))))?

  dir_shown = target.dir.display ().to_string ()
  watched = watcher.watch (&target.dir) RecursiveMode::Recursive
  (.map_err (\e -> format! \-> "error: cannot watch #{dir_shown}: #{e}") watched)?

  eprintln! \-> "watching #{dir_shown} — press ctrl-c to stop"

  for received in rx
    case received of
      Ok event ->
        if is_change (&event.kind) && .any (\p -> is_relevant p) (.iter event.paths)
          report (&target) plain
      Err e -> eprintln! \-> "error: the file watcher reported: #{e}"

  Ok ()

export(crate) is_change : &EventKind -> bool
is_change kind = case kind of
  EventKind::Create _ or EventKind::Remove _ -> true
  EventKind::Any or EventKind::Other -> true
  EventKind::Modify inner -> case inner of
    ModifyKind::Data _ or ModifyKind::Name _ -> true
    _ -> false
  EventKind::Access _ -> false

test "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 "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 "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 "creating and removing are changes"
  assert! \-> is_change (&EventKind::Create notify::event::CreateKind::File)
  assert! \-> is_change (&EventKind::Remove notify::event::RemoveKind::File)

test "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 "an unknown kind is treated as a change"
  assert! \-> is_change (&EventKind::Any)
  assert! \-> is_change (&EventKind::Other)

export(crate) is_relevant : &Path -> bool
is_relevant path =
  guard .map (\n -> .to_string (.to_string_lossy n)) (.file_name path) is Some name else
    return false

  if name == "4913" || .ends_with '~' name || .starts_with ".#" name || .starts_with '#' name && .ends_with '#' name
    return false

  extension = .map (\e -> .to_string (.to_string_lossy e)) (.extension path)
  case .as_deref extension of
    Some e -> (e == "vlt" || e == "sdx") && !.starts_with '.' name
    _ -> false

test "a vlt file is relevant"
  assert! \-> is_relevant (Path::new "src/main.vlt")
  assert! \-> is_relevant (Path::new "main.vlt")

test "another extension is not"
  for name in ["main.rs", "main.vrs", "Cargo.toml", "README.md", "main"]
    assert! \-> !is_relevant (Path::new name) \-> "#{name}"

test "the generated output is not relevant"
  assert! \-> !is_relevant (Path::new "main.vrs")
  assert! \-> !is_relevant (Path::new "main.vrs.map")

test "editor temporaries are ignored"
  for name in ["4913", "main.vlt~", ".main.vlt.swp", ".\#main.vlt", "\#main.vlt\#", ".main.vlt"]
    assert! \-> !is_relevant (Path::new name) \-> "#{name} should be ignored"

test "a path with no file name is not relevant"
  assert! \-> !is_relevant (Path::new "/")
  assert! \-> !is_relevant (Path::new "..")

test "an unfamiliar name still compiles if it is a vlt"
  assert! \-> is_relevant (Path::new "weird-editor-artifact.vlt")

export(crate) report : &Target -> bool -> ()
report target plain =
  case &target.file of
    Some file ->
      shown = file.display ().to_string ()
      run_build (&shown) plain
    None ->
      for source in sources (&target.dir)
        run_build (&source) plain
  ()

export(crate) run_build : &str -> bool -> ()
run_build input plain =
  mut args = vec! \-> .to_string input
  if plain
    args.push (.to_string "--plain")

  case build_command (&args) of
    Ok _ -> eprintln! \-> "built #{input}"
    Err message -> eprintln! \-> "#{message}"
  ()

export(crate) sources : &Path -> Vec<String>
sources dir =
  guard std::fs::read_dir dir is Ok entries else
    return Vec::new ()
  mut found : Vec<String> = ((((entries.filter_map Result::ok).map (\e -> .path e)).filter (\p -> .is_file p && .is_some_and (\x -> x == "vlt" || x == "sdx") (.extension p))).map (\p -> .to_string (.display p))).collect ()
  .sort found
  found

test "finds and sorts vlt files"
  pid = std::process::id ()
  dir = .join (format! \-> "voltaic-watch-src-#{pid}") (std::env::temp_dir ())
  .expect "should create" $ std::fs::create_dir_all (&dir)
  for name in ["b.vlt", "a.vlt", "ignored.rs"]
    .expect "should write" $ std::fs::write (.join name dir) "main : ()\nmain =\n  println! \\-> \"\#{1}\"\n"

  found = sources (&dir)
  assert_eq! \-> .len found \-> 2 \-> "#{found:?}"
  assert! \-> .ends_with "a.vlt" found[0] && .ends_with "b.vlt" found[1] \-> "#{found:?}"

  _dropped = .ok $ std::fs::remove_dir_all (&dir)

test "an unreadable directory yields nothing"
  found = sources $ std::path::Path::new "/nonexistent/voltaic"
  assert! \-> .is_empty found

export(crate) data Target { export(crate) dir PathBuf, export(crate) file Option<PathBuf> }
  deriving (Debug, Clone, PartialEq, Eq)

instance Target
  export(crate) resolve : &Path -> Result<Self, String>
  resolve arg =
    if .is_dir arg
      return Ok (Target { dir = .to_path_buf arg, file = None })

    if !.exists arg
      shown = arg.display ().to_string ()
      return Err (format! \-> "error: #{shown} does not exist\n\n`voltaic watch` takes a .vlt file or a directory of them.")

    parent = .filter (\p -> !.is_empty (.as_os_str p)) (.parent arg)
    dir = (parent.unwrap_or (Path::new ".")).to_path_buf ()
    Ok (Target { dir, file = Some (.to_path_buf arg) })

#[cfg(test)]
newtype Dir = Dir PathBuf

#[cfg(test)]
static NEXT : std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new 0

#[cfg(test)]
instance Dir
  new : &str -> Self
  new tag =
    n = .fetch_add 1 std::sync::atomic::Ordering::Relaxed NEXT
    pid = std::process::id ()
    dir = .join (format! \-> "voltaic-target-#{tag}-#{pid}-#{n}") (std::env::temp_dir ())
    .expect "should create" $ std::fs::create_dir_all (&dir)
    Dir dir

#[cfg(test)]
instance Drop Dir
  drop : &mut self -> ()
  drop self =
    _dropped = .ok $ std::fs::remove_dir_all (&self.0)
    ()

test "a directory is watched whole"
  dir = Dir::new "whole"
  target = .expect "should resolve" $ Target::resolve (&dir.0)
  assert_eq! \-> target.dir \-> dir.0
  assert_eq! \-> target.file \-> None \-> "a directory compiles all of itself"

test "a file is watched through its parent"
  dir = Dir::new "parent"
  file = .join "main.vlt" dir.0
  .expect "should write" $ std::fs::write (&file) "main : ()\nmain =\n  println! \\-> \"\#{1}\"\n"

  target = .expect "should resolve" $ Target::resolve (&file)
  assert_eq! \-> target.dir \-> dir.0 \-> "the parent directory is what is watched"
  assert_eq! \-> target.file \-> Some file

test "a missing path is an error that says what is accepted"
  err = .expect_err "should fail" $ Target::resolve $ Path::new "/nonexistent/main.vlt"
  assert! \-> .contains "does not exist" err \-> "#{err}"
  assert! \-> .contains ".vlt file or a directory" err \-> "#{err}"

test "a bare filename resolves to the current directory"
  dir = Dir::new "bare"
  file = .join "main.vlt" dir.0
  .expect "should write" $ std::fs::write (&file) "main : ()\nmain =\n  println! \\-> \"\#{1}\"\n"

  previous = .expect "a cwd" $ std::env::current_dir ()
  .expect "should chdir" $ std::env::set_current_dir (&dir.0)
  target = .expect "should resolve" $ Target::resolve $ Path::new "main.vlt"
  .expect "should restore" $ std::env::set_current_dir previous

  assert_eq! \-> target.dir \-> Path::new "." \-> "not the empty path"