From 7c2202accf7f1e80da43b6e63a86d51de6970dd0 Mon Sep 17 00:00:00 2001 From: Rebecca Turner Date: Thu, 28 Sep 2023 10:12:31 -0700 Subject: [PATCH] Add `--watch-restart` and `--watch-extension` flags (#105) When `--watch-restart` paths change, the ghci session is restarted (like when modules are removed or renamed). ghcid-ng also restarts on `.cabal` file and `.ghci` changes. When files with a `--watch-extension` change, the ghci session is reloaded. --- src/clap/mod.rs | 2 + src/clap/normal_path.rs | 38 +++++++ src/cli.rs | 34 ++++++- src/event_filter.rs | 21 ++-- src/ghci/mod.rs | 79 ++++++++++----- src/main.rs | 4 +- src/normal_path.rs | 17 ++++ src/watcher.rs | 12 ++- test-harness/src/ghcid_ng.rs | 10 +- tests/basic.rs | 187 ----------------------------------- tests/load.rs | 44 +++++++++ tests/reload.rs | 88 +++++++++++++++++ tests/restart.rs | 89 +++++++++++++++++ tests/watch_extension.rs | 26 +++++ 14 files changed, 411 insertions(+), 240 deletions(-) create mode 100644 src/clap/normal_path.rs delete mode 100644 tests/basic.rs create mode 100644 tests/load.rs create mode 100644 tests/reload.rs create mode 100644 tests/restart.rs create mode 100644 tests/watch_extension.rs diff --git a/src/clap/mod.rs b/src/clap/mod.rs index f6bd7fe9..3d384ce2 100644 --- a/src/clap/mod.rs +++ b/src/clap/mod.rs @@ -6,6 +6,7 @@ mod error_message; mod fmt_span; mod ghci_command; mod humantime; +mod normal_path; mod rust_backtrace; pub use self::humantime::DurationValueParser; @@ -13,4 +14,5 @@ pub use clonable_command::ClonableCommandParser; pub use error_message::value_validation_error; pub use fmt_span::FmtSpanParserFactory; pub use ghci_command::GhciCommandParser; +pub use normal_path::NormalPathValueParser; pub use rust_backtrace::RustBacktrace; diff --git a/src/clap/normal_path.rs b/src/clap/normal_path.rs new file mode 100644 index 00000000..5bac228d --- /dev/null +++ b/src/clap/normal_path.rs @@ -0,0 +1,38 @@ +//! Adapter for parsing [`NormalPath`]s with a [`clap::builder::Arg::value_parser`]. + +use clap::builder::PathBufValueParser; +use clap::builder::TypedValueParser; +use clap::builder::ValueParserFactory; + +use crate::normal_path::NormalPath; + +/// [`clap`] parser for [`NormalPath`] values. +#[derive(Default, Clone)] +pub struct NormalPathValueParser { + inner: PathBufValueParser, +} + +impl TypedValueParser for NormalPathValueParser { + type Value = NormalPath; + + fn parse_ref( + &self, + cmd: &clap::Command, + arg: Option<&clap::Arg>, + value: &std::ffi::OsStr, + ) -> Result { + self.inner.parse_ref(cmd, arg, value).and_then(|path_buf| { + NormalPath::from_cwd(path_buf).map_err(|err| { + super::value_validation_error(arg, &value.to_string_lossy(), format!("{err:?}")) + }) + }) + } +} + +impl ValueParserFactory for NormalPath { + type Parser = NormalPathValueParser; + + fn value_parser() -> Self::Parser { + Self::Parser::default() + } +} diff --git a/src/cli.rs b/src/cli.rs index 09c2f6dc..51428b68 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,6 +11,7 @@ use crate::clap::FmtSpanParserFactory; use crate::clap::RustBacktrace; use crate::command::ClonableCommand; use crate::ghci::GhciCommand; +use crate::normal_path::NormalPath; /// A `ghci`-based file watcher and Haskell recompiler. #[derive(Debug, Clone, Parser)] @@ -26,14 +27,14 @@ pub struct Opts { pub command: Option, /// A file to write compilation errors to. This is analogous to `ghcid.txt`. - #[arg(long)] + #[arg(long, alias = "outputfile")] pub errors: Option, /// Enable evaluating commands. /// /// This parses line commands starting with `-- $>` or multiline commands delimited by `{- $>` /// and `<$ -}` and evaluates them after reloads. - #[arg(long)] + #[arg(long, alias = "allow-eval")] pub enable_eval: bool, /// Lifecycle hooks and commands to run at various points. @@ -74,7 +75,21 @@ pub struct WatchOpts { /// A path to watch for changes. Can be given multiple times. #[arg(long = "watch")] - pub paths: Vec, + pub paths: Vec, + + /// Restart the ghci session when these paths change. + /// Defaults to `.ghci` and any `.cabal` file. + /// Can be given multiple times. + #[arg(long = "watch-restart")] + pub restart_paths: Vec, + + /// Reload when files with this extension change. Can be used to add non-Haskell source files + /// (like files included with Template Haskell, such as model definitions) to the build. + /// Unlike Haskell source files, files with these extensions will only trigger `:reload`s and + /// will never be `:add`ed to the ghci session. + /// Can be given multiple times. + #[arg(long = "watch-extension")] + pub extensions: Vec, } // TODO: Possibly set `RUST_LIB_BACKTRACE` from `RUST_BACKTRACE` as well, so that `full` @@ -129,17 +144,20 @@ pub struct LoggingOpts { pub struct HookOpts { /// `ghci` commands which runs tests, like `TestMain.testMain`. If given, these commands will be /// run after reloads. + /// Can be given multiple times. #[arg(long, value_name = "GHCI_COMMAND")] pub test_ghci: Vec, /// Shell commands to run before starting or restarting `ghci`. /// /// This can be used to regenerate `.cabal` files with `hpack`. + /// Can be given multiple times. #[arg(long, value_name = "SHELL_COMMAND")] pub before_startup_shell: Vec, /// `ghci` commands to run on startup. Use `:set args ...` in combination with `--test` to set /// the command-line arguments for tests. + /// Can be given multiple times. #[arg(long, value_name = "GHCI_COMMAND")] pub after_startup_ghci: Vec, @@ -147,20 +165,24 @@ pub struct HookOpts { /// /// These are run when modules are change on disk; this does not necessarily correspond to a /// `:reload` command. + /// Can be given multiple times. #[arg(long, value_name = "GHCI_COMMAND")] pub before_reload_ghci: Vec, /// `ghci` commands to run after reloading `ghci`. + /// Can be given multiple times. #[arg(long, value_name = "GHCI_COMMAND")] pub after_reload_ghci: Vec, /// `ghci` commands to run before restarting `ghci`. /// /// See `--after-restart-ghci` for more details. + /// Can be given multiple times. #[arg(long, value_name = "GHCI_COMMAND")] pub before_restart_ghci: Vec, /// `ghci` commands to run after restarting `ghci`. + /// Can be given multiple times. /// /// `ghci` cannot reload after files are deleted due to a bug, so `ghcid-ng` has to restart the /// underlying `ghci` session when this happens. Note that the `--before-restart-ghci` and @@ -175,13 +197,15 @@ pub struct HookOpts { impl Opts { /// Perform late initialization of the command-line arguments. If `init` isn't called before /// the arguments are used, the behavior is undefined. - pub fn init(&mut self) { + pub fn init(&mut self) -> miette::Result<()> { if self.watch.paths.is_empty() { - self.watch.paths.push("src".into()); + self.watch.paths.push(NormalPath::from_cwd("src")?); } // These help our libraries (particularly `color-eyre`) see these options. // The options are provided mostly for documentation. std::env::set_var("RUST_BACKTRACE", self.logging.backtrace.to_string()); + + Ok(()) } } diff --git a/src/event_filter.rs b/src/event_filter.rs index b46d7a2f..10a6f7ff 100644 --- a/src/event_filter.rs +++ b/src/event_filter.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; +use camino::Utf8Path; use camino::Utf8PathBuf; use miette::IntoDiagnostic; use watchexec::action::Action; @@ -10,8 +11,6 @@ use watchexec::event::filekind::ModifyKind; use watchexec::event::Event; use watchexec::event::Tag; -use crate::haskell_source_file::is_haskell_source_file; - /* # Notes on reacting to file events @@ -80,6 +79,16 @@ pub enum FileEvent { Remove(Utf8PathBuf), } +impl FileEvent { + /// Get the contained path. + pub fn as_path(&self) -> &Utf8Path { + match self { + FileEvent::Modify(path) => path.as_path(), + FileEvent::Remove(path) => path.as_path(), + } + } +} + /// Process the events contained in an [`Action`] into a list of [`FileEvent`]s. pub fn file_events_from_action(action: &Action) -> miette::Result> { // First, build up a map from paths to events tagged with that path. @@ -100,14 +109,6 @@ pub fn file_events_from_action(action: &Action) -> miette::Result let mut ret = Vec::new(); for (path, events) in events_by_path.iter() { - if !is_haskell_source_file(path) { - // If the path doesn't have a Haskell source extension, we don't need to process it. - // In the future, we'll want something more sophisticated here -- we'll need to reload - // for non-Haskell files or even run commands when non-Haskell files change -- but this - // is fine for a first pass. - continue; - } - let mut exists = false; let mut created = false; let mut modified = false; diff --git a/src/ghci/mod.rs b/src/ghci/mod.rs index 4fe31dcf..99b078fc 100644 --- a/src/ghci/mod.rs +++ b/src/ghci/mod.rs @@ -55,6 +55,7 @@ use crate::command; use crate::command::ClonableCommand; use crate::event_filter::FileEvent; use crate::ghci::parse::ShowPaths; +use crate::haskell_source_file::is_haskell_source_file; use crate::incremental_reader::IncrementalReader; use crate::normal_path::NormalPath; use crate::sync_sentinel::SyncSentinel; @@ -87,6 +88,11 @@ pub struct GhciOpts { pub enable_eval: bool, /// Lifecycle hooks, mostly `ghci` commands to run at certain points. pub hooks: HookOpts, + /// Restart the `ghci` session when these paths are changed. + pub restart_paths: Vec, + /// Reload the `ghci` session when files with these extensions are changed, in addition to + /// Haskell source files. + pub extra_extensions: Vec, } impl GhciOpts { @@ -107,6 +113,8 @@ impl GhciOpts { error_path: opts.errors.clone(), enable_eval: opts.enable_eval, hooks: opts.hooks.clone(), + restart_paths: opts.watch.restart_paths.clone(), + extra_extensions: opts.watch.extensions.clone(), }) } } @@ -288,29 +296,48 @@ impl Ghci { let mut needs_reload = Vec::new(); let mut needs_add = Vec::new(); for event in events { - match event { - FileEvent::Remove(path) => { - // `ghci` can't cope with removed modules, so we need to fully restart the - // `ghci` process in case any modules are removed or renamed. - // - // https://gitlab.haskell.org/ghc/ghc/-/issues/11596 - // - // TODO: I should investigate if `:unadd` works for some classes of removed - // modules. - tracing::debug!(%path, "Needs restart"); - needs_restart.push(self.relative_path(path)?); - } - FileEvent::Modify(path) => { - let path = self.relative_path(path)?; - if self.targets.contains_source_path(path.absolute())? { - // We can `:reload` paths in the target set. - tracing::debug!(%path, "Needs reload"); - needs_reload.push(path); - } else { - // Otherwise we need to `:add` the new paths. - tracing::debug!(%path, "Needs add"); - needs_add.push(path); - } + let path = event.as_path(); + let path = self.relative_path(path)?; + // Restart on `.cabal` files, `.ghci` files, and any paths under the `restart_paths`. + if path.extension().map(|ext| ext == "cabal").unwrap_or(false) + || path + .file_name() + .map(|name| name == ".ghci") + .unwrap_or(false) + || self + .opts + .restart_paths + .iter() + .any(|restart_path| path.starts_with(restart_path)) + // `ghci` can't cope with removed modules, so we need to fully restart the + // `ghci` process in case any modules are removed or renamed. + // + // https://gitlab.haskell.org/ghc/ghc/-/issues/11596 + // + // TODO: I should investigate if `:unadd` works for some classes of removed + // modules. + || (matches!(event, FileEvent::Remove(_)) && is_haskell_source_file(&path)) + { + // Restart for this path. + tracing::debug!(%path, "Needs restart"); + needs_restart.push(path); + } else if path + .extension() + .map(|ext| self.opts.extra_extensions.contains(&ext.to_owned())) + .unwrap_or(false) + { + // Extra extensions are always reloaded, never added. + tracing::debug!(%path, "Needs reload"); + needs_reload.push(path); + } else if matches!(event, FileEvent::Modify(_)) && is_haskell_source_file(&path) { + if self.targets.contains_source_path(path.absolute())? { + // We can `:reload` paths in the target set. + tracing::debug!(%path, "Needs reload"); + needs_reload.push(path); + } else { + // Otherwise we need to `:add` the new paths. + tracing::debug!(%path, "Needs add"); + needs_add.push(path); } } } @@ -336,7 +363,7 @@ impl Ghci { if !actions.needs_restart.is_empty() { tracing::info!( - "Restarting ghci due to deleted/moved modules:\n{}", + "Restarting ghci:\n{}", format_bulleted_list(&actions.needs_restart) ); for command in &self.opts.hooks.before_restart_ghci { @@ -363,7 +390,7 @@ impl Ghci { if !actions.needs_add.is_empty() { tracing::info!( - "Adding new modules to ghci:\n{}", + "Adding modules to ghci:\n{}", format_bulleted_list(&actions.needs_add) ); for path in &actions.needs_add { @@ -376,7 +403,7 @@ impl Ghci { if !actions.needs_reload.is_empty() { tracing::info!( - "Reloading ghci due to changed modules:\n{}", + "Reloading ghci:\n{}", format_bulleted_list(&actions.needs_reload) ); let messages = self.stdin.reload(&mut self.stdout).await?; diff --git a/src/main.rs b/src/main.rs index 4eabfbdc..b5ecb4ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,12 +13,12 @@ use ghcid_ng::watcher::Watcher; use ghcid_ng::watcher::WatcherOpts; use miette::IntoDiagnostic; use miette::WrapErr; -use tap::Tap; #[tokio::main] async fn main() -> miette::Result<()> { miette::set_panic_hook(); - let opts = cli::Opts::parse().tap_mut(|opts| opts.init()); + let mut opts = cli::Opts::parse(); + opts.init()?; tracing::TracingOpts::from_cli(&opts).install()?; ::tracing::warn!( diff --git a/src/normal_path.rs b/src/normal_path.rs index 3ef61b78..ed6e2004 100644 --- a/src/normal_path.rs +++ b/src/normal_path.rs @@ -10,6 +10,7 @@ use std::path::Path; use camino::Utf8Path; use camino::Utf8PathBuf; use miette::miette; +use miette::Context; use miette::IntoDiagnostic; use path_absolutize::Absolutize; @@ -42,6 +43,16 @@ impl NormalPath { Ok(Self { normal, relative }) } + /// Create a new normalized path relative to the current working directory. + pub fn from_cwd(original: impl AsRef) -> miette::Result { + Self::new( + original, + std::env::current_dir() + .into_diagnostic() + .wrap_err("Failed to get current directory")?, + ) + } + /// Get a reference to the absolute (normalized) path, borrowed as a [`Utf8Path`]. pub fn absolute(&self) -> &Utf8Path { self.normal.as_path() @@ -103,6 +114,12 @@ impl AsRef for NormalPath { } } +impl AsRef for NormalPath { + fn as_ref(&self) -> &Path { + self.normal.as_std_path() + } +} + impl Borrow for NormalPath { fn borrow(&self) -> &Utf8PathBuf { &self.normal diff --git a/src/watcher.rs b/src/watcher.rs index e3eba6a1..9172ae8f 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -4,7 +4,6 @@ use std::error::Error; use std::sync::Arc; use std::time::Duration; -use camino::Utf8PathBuf; use tokio::runtime::Handle; use tokio::task::block_in_place; use tokio::task::JoinHandle; @@ -23,16 +22,21 @@ use watchexec_signals::Signal; use crate::cli::Opts; use crate::event_filter::file_events_from_action; use crate::ghci::Ghci; +use crate::normal_path::NormalPath; /// Options for constructing a [`Watcher`]. This is like a lower-effort builder interface, mostly /// provided because Rust tragically lacks named arguments. pub struct WatcherOpts<'opts> { /// The paths to watch for changes. - pub watch: &'opts [Utf8PathBuf], + pub watch: &'opts [NormalPath], + /// Paths to watch for changes and restart the `ghci` session on. + pub watch_restart: &'opts [NormalPath], /// Debounce duration for filesystem events. pub debounce: Duration, /// If given, use the polling file watcher with the given duration as the poll interval. pub poll: Option, + /// Extra file extensions to reload on. + pub extra_extensions: &'opts [String], } impl<'opts> WatcherOpts<'opts> { @@ -43,8 +47,10 @@ impl<'opts> WatcherOpts<'opts> { pub fn from_cli(opts: &'opts Opts) -> Self { Self { watch: &opts.watch.paths, + watch_restart: &opts.watch.restart_paths, debounce: opts.watch.debounce, poll: opts.watch.poll, + extra_extensions: &opts.watch.extensions, } } } @@ -89,7 +95,7 @@ impl Watcher { let mut runtime_config = RuntimeConfig::default(); runtime_config - .pathset(opts.watch) + .pathset(opts.watch.iter().chain(opts.watch_restart)) .action_throttle(opts.debounce) .on_action(action_handler); diff --git a/test-harness/src/ghcid_ng.rs b/test-harness/src/ghcid_ng.rs index 3b2dfea5..70f95328 100644 --- a/test-harness/src/ghcid_ng.rs +++ b/test-harness/src/ghcid_ng.rs @@ -303,15 +303,13 @@ impl GhcidNg { /// Wait until `ghcid-ng` reloads the `ghci` session due to changed modules. pub async fn wait_until_reload(&mut self) -> miette::Result<()> { // TODO: It would be nice to verify which modules are changed. - self.assert_logged("Reloading ghci due to changed modules") - .await - .map(|_| ()) + self.assert_logged("^Reloading ghci:\n").await.map(|_| ()) } /// Wait until `ghcid-ng` adds new modules to the `ghci` session. pub async fn wait_until_add(&mut self) -> miette::Result<()> { // TODO: It would be nice to verify which modules are being added. - self.assert_logged("Adding new modules to ghci") + self.assert_logged("^Adding modules to ghci:\n") .await .map(|_| ()) } @@ -319,9 +317,7 @@ impl GhcidNg { /// Wait until `ghcid-ng` restarts the `ghci` session. pub async fn wait_until_restart(&mut self) -> miette::Result<()> { // TODO: It would be nice to verify which modules have been deleted/moved. - self.assert_logged("Restarting ghci due to deleted/moved modules") - .await - .map(|_| ()) + self.assert_logged("^Restarting ghci:\n").await.map(|_| ()) } /// Get a path relative to the project root. diff --git a/tests/basic.rs b/tests/basic.rs deleted file mode 100644 index 874d25d4..00000000 --- a/tests/basic.rs +++ /dev/null @@ -1,187 +0,0 @@ -use indoc::indoc; - -use test_harness::fs; -use test_harness::test; -use test_harness::GhcidNg; -use test_harness::Matcher; - -/// Test that `ghcid-ng` can start up `ghci` and load a session. -#[test] -async fn can_load() { - let mut session = GhcidNg::new("tests/data/simple") - .await - .expect("ghcid-ng starts"); - session - .wait_until_ready() - .await - .expect("ghcid-ng loads ghci"); -} - -/// Test that `ghcid-ng` can start up and then reload on changes. -#[test] -async fn can_reload() { - let mut session = GhcidNg::new("tests/data/simple") - .await - .expect("ghcid-ng starts"); - session - .wait_until_ready() - .await - .expect("ghcid-ng loads ghci"); - fs::append( - session.path("src/MyLib.hs"), - indoc!( - " - - hello = 1 :: Integer - - " - ), - ) - .await - .unwrap(); - session - .wait_until_reload() - .await - .expect("ghcid-ng reloads on changes"); - session - .assert_logged( - Matcher::span_close() - .in_module("ghcid_ng::ghci") - .in_spans(["on_action", "reload"]), - ) - .await - .expect("ghcid-ng finishes reloading"); -} - -/// Test that `ghcid-ng` can load new modules. -#[test] -async fn can_load_new_module() { - let mut session = GhcidNg::new("tests/data/simple") - .await - .expect("ghcid-ng starts"); - session - .wait_until_ready() - .await - .expect("ghcid-ng loads ghci"); - fs::write( - session.path("src/My/Module.hs"), - indoc!( - "module My.Module (myIdent) where - myIdent :: () - myIdent = () - " - ), - ) - .await - .unwrap(); - session - .wait_until_add() - .await - .expect("ghcid-ng loads new modules"); -} - -/// Test that `ghcid-ng` can reload a module that fails to compile. -#[test] -async fn can_reload_after_error() { - let mut session = GhcidNg::new("tests/data/simple") - .await - .expect("ghcid-ng starts"); - session - .wait_until_ready() - .await - .expect("ghcid-ng loads ghci"); - let new_module = session.path("src/My/Module.hs"); - - fs::write( - &new_module, - indoc!( - "module My.Module (myIdent) where - myIdent :: () - myIdent = \"Uh oh!\" - " - ), - ) - .await - .unwrap(); - session - .wait_until_add() - .await - .expect("ghcid-ng loads new modules"); - session - .assert_logged(Matcher::message("Compilation failed").in_spans(["reload", "add_module"])) - .await - .unwrap(); - - fs::replace(&new_module, "myIdent = \"Uh oh!\"", "myIdent = ()") - .await - .unwrap(); - - session - .wait_until_add() - .await - .expect("ghcid-ng reloads on changes"); - session - .assert_logged(Matcher::message("Compilation succeeded").in_span("reload")) - .await - .unwrap(); -} - -/// Test that `ghcid-ng` can restart `ghci` after a module is moved. -#[test] -async fn can_restart_after_module_move() { - let mut session = GhcidNg::new("tests/data/simple") - .await - .expect("ghcid-ng starts"); - session - .wait_until_ready() - .await - .expect("ghcid-ng loads ghci"); - - let module_path = session.path("src/My/Module.hs"); - fs::write( - &module_path, - indoc!( - "module My.Module (myIdent) where - myIdent :: () - myIdent = () - " - ), - ) - .await - .unwrap(); - session - .wait_until_add() - .await - .expect("ghcid-ng loads new modules"); - - { - // Rename the module and fix the module name to match the new path. - let contents = fs::read(&module_path).await.unwrap(); - fs::remove(&module_path).await.unwrap(); - fs::write( - session.path("src/My/CoolModule.hs"), - contents.replace("module My.Module", "module My.CoolModule"), - ) - .await - .unwrap(); - } - - session - .wait_until_restart() - .await - .expect("ghcid-ng restarts ghci"); - - session - .assert_logged( - Matcher::message("Compiling") - .in_span("reload") - .with_field("module", r"My\.CoolModule"), - ) - .await - .unwrap(); - - session - .assert_logged(Matcher::message("Compilation succeeded").in_span("reload")) - .await - .unwrap(); -} diff --git a/tests/load.rs b/tests/load.rs new file mode 100644 index 00000000..bac02224 --- /dev/null +++ b/tests/load.rs @@ -0,0 +1,44 @@ +use indoc::indoc; + +use test_harness::fs; +use test_harness::test; +use test_harness::GhcidNg; + +/// Test that `ghcid-ng` can start up `ghci` and load a session. +#[test] +async fn can_load() { + let mut session = GhcidNg::new("tests/data/simple") + .await + .expect("ghcid-ng starts"); + session + .wait_until_ready() + .await + .expect("ghcid-ng loads ghci"); +} + +/// Test that `ghcid-ng` can load new modules. +#[test] +async fn can_load_new_module() { + let mut session = GhcidNg::new("tests/data/simple") + .await + .expect("ghcid-ng starts"); + session + .wait_until_ready() + .await + .expect("ghcid-ng loads ghci"); + fs::write( + session.path("src/My/Module.hs"), + indoc!( + "module My.Module (myIdent) where + myIdent :: () + myIdent = () + " + ), + ) + .await + .unwrap(); + session + .wait_until_add() + .await + .expect("ghcid-ng loads new modules"); +} diff --git a/tests/reload.rs b/tests/reload.rs new file mode 100644 index 00000000..0aab375a --- /dev/null +++ b/tests/reload.rs @@ -0,0 +1,88 @@ +use indoc::indoc; + +use test_harness::fs; +use test_harness::test; +use test_harness::GhcidNg; +use test_harness::Matcher; + +/// Test that `ghcid-ng` can start up and then reload on changes. +#[test] +async fn can_reload() { + let mut session = GhcidNg::new("tests/data/simple") + .await + .expect("ghcid-ng starts"); + session + .wait_until_ready() + .await + .expect("ghcid-ng loads ghci"); + fs::append( + session.path("src/MyLib.hs"), + indoc!( + " + + hello = 1 :: Integer + + " + ), + ) + .await + .unwrap(); + session + .wait_until_reload() + .await + .expect("ghcid-ng reloads on changes"); + session + .assert_logged( + Matcher::span_close() + .in_module("ghcid_ng::ghci") + .in_spans(["on_action", "reload"]), + ) + .await + .expect("ghcid-ng finishes reloading"); +} + +/// Test that `ghcid-ng` can reload a module that fails to compile. +#[test] +async fn can_reload_after_error() { + let mut session = GhcidNg::new("tests/data/simple") + .await + .expect("ghcid-ng starts"); + session + .wait_until_ready() + .await + .expect("ghcid-ng loads ghci"); + let new_module = session.path("src/My/Module.hs"); + + fs::write( + &new_module, + indoc!( + "module My.Module (myIdent) where + myIdent :: () + myIdent = \"Uh oh!\" + " + ), + ) + .await + .unwrap(); + session + .wait_until_add() + .await + .expect("ghcid-ng loads new modules"); + session + .assert_logged(Matcher::message("Compilation failed").in_spans(["reload", "add_module"])) + .await + .unwrap(); + + fs::replace(&new_module, "myIdent = \"Uh oh!\"", "myIdent = ()") + .await + .unwrap(); + + session + .wait_until_add() + .await + .expect("ghcid-ng reloads on changes"); + session + .assert_logged(Matcher::message("Compilation succeeded").in_span("reload")) + .await + .unwrap(); +} diff --git a/tests/restart.rs b/tests/restart.rs new file mode 100644 index 00000000..c1ea8c57 --- /dev/null +++ b/tests/restart.rs @@ -0,0 +1,89 @@ +use indoc::indoc; + +use test_harness::fs; +use test_harness::test; +use test_harness::GhcidNg; +use test_harness::GhcidNgBuilder; +use test_harness::Matcher; + +/// Test that `ghcid-ng` can restart `ghci` after a module is moved. +#[test] +async fn can_restart_after_module_move() { + let mut session = GhcidNg::new("tests/data/simple") + .await + .expect("ghcid-ng starts"); + session + .wait_until_ready() + .await + .expect("ghcid-ng loads ghci"); + + let module_path = session.path("src/My/Module.hs"); + fs::write( + &module_path, + indoc!( + "module My.Module (myIdent) where + myIdent :: () + myIdent = () + " + ), + ) + .await + .unwrap(); + session + .wait_until_add() + .await + .expect("ghcid-ng loads new modules"); + + { + // Rename the module and fix the module name to match the new path. + let contents = fs::read(&module_path).await.unwrap(); + fs::remove(&module_path).await.unwrap(); + fs::write( + session.path("src/My/CoolModule.hs"), + contents.replace("module My.Module", "module My.CoolModule"), + ) + .await + .unwrap(); + } + + session + .wait_until_restart() + .await + .expect("ghcid-ng restarts ghci"); + + session + .assert_logged( + Matcher::message("Compiling") + .in_span("reload") + .with_field("module", r"My\.CoolModule"), + ) + .await + .unwrap(); + + session + .assert_logged(Matcher::message("Compilation succeeded").in_span("reload")) + .await + .unwrap(); +} + +/// Test that `ghcid-ng` can restart after a custom `--watch-restart` path changes. +#[test] +async fn can_restart_on_custom_file_change() { + let mut session = GhcidNgBuilder::new("tests/data/simple") + .with_args(["--watch-restart", "package.yaml"]) + .start() + .await + .expect("ghcid-ng starts"); + + session + .wait_until_ready() + .await + .expect("ghcid-ng loads ghci"); + + fs::touch(session.path("package.yaml")).await.unwrap(); + + session + .wait_until_restart() + .await + .expect("ghcid-ng restarts when package.yaml changes"); +} diff --git a/tests/watch_extension.rs b/tests/watch_extension.rs new file mode 100644 index 00000000..0c18e5f3 --- /dev/null +++ b/tests/watch_extension.rs @@ -0,0 +1,26 @@ +use test_harness::fs; +use test_harness::test; +use test_harness::GhcidNgBuilder; + +/// Test that `ghcid-ng` can reload when a file with a `--watch-extension` is changed. +#[test] +async fn can_reload_extra_extension() { + let mut session = GhcidNgBuilder::new("tests/data/simple") + .with_args(["--watch-extension", "persistentmodels"]) + .start() + .await + .expect("ghcid-ng starts"); + session + .wait_until_ready() + .await + .expect("ghcid-ng loads ghci"); + + fs::touch(session.path("src/my_model.persistentmodels")) + .await + .unwrap(); + + session + .wait_until_reload() + .await + .expect("ghcid-ng reloads when a `.persistentmodels` file is created"); +}