Skip to content

Commit

Permalink
Add --watch-restart and --watch-extension flags (#105)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
9999years authored Sep 28, 2023
1 parent 5ed6199 commit 7c2202a
Show file tree
Hide file tree
Showing 14 changed files with 411 additions and 240 deletions.
2 changes: 2 additions & 0 deletions src/clap/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ mod error_message;
mod fmt_span;
mod ghci_command;
mod humantime;
mod normal_path;
mod rust_backtrace;

pub use self::humantime::DurationValueParser;
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;
38 changes: 38 additions & 0 deletions src/clap/normal_path.rs
Original file line number Diff line number Diff line change
@@ -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::Value, clap::Error> {
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()
}
}
34 changes: 29 additions & 5 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -26,14 +27,14 @@ pub struct Opts {
pub command: Option<ClonableCommand>,

/// A file to write compilation errors to. This is analogous to `ghcid.txt`.
#[arg(long)]
#[arg(long, alias = "outputfile")]
pub errors: Option<Utf8PathBuf>,

/// 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.
Expand Down Expand Up @@ -74,7 +75,21 @@ pub struct WatchOpts {

/// A path to watch for changes. Can be given multiple times.
#[arg(long = "watch")]
pub paths: Vec<Utf8PathBuf>,
pub paths: Vec<NormalPath>,

/// 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<NormalPath>,

/// 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<String>,
}

// TODO: Possibly set `RUST_LIB_BACKTRACE` from `RUST_BACKTRACE` as well, so that `full`
Expand Down Expand Up @@ -129,38 +144,45 @@ 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<GhciCommand>,

/// 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<ClonableCommand>,

/// `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<GhciCommand>,

/// `ghci` commands to run before reloading `ghci`.
///
/// 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<GhciCommand>,

/// `ghci` commands to run after reloading `ghci`.
/// Can be given multiple times.
#[arg(long, value_name = "GHCI_COMMAND")]
pub after_reload_ghci: Vec<GhciCommand>,

/// `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<GhciCommand>,

/// `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
Expand All @@ -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(())
}
}
21 changes: 11 additions & 10 deletions src/event_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
use std::collections::HashMap;

use camino::Utf8Path;
use camino::Utf8PathBuf;
use miette::IntoDiagnostic;
use watchexec::action::Action;
Expand All @@ -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
Expand Down Expand Up @@ -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<Vec<FileEvent>> {
// First, build up a map from paths to events tagged with that path.
Expand All @@ -100,14 +109,6 @@ pub fn file_events_from_action(action: &Action) -> miette::Result<Vec<FileEvent>
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;
Expand Down
79 changes: 53 additions & 26 deletions src/ghci/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<NormalPath>,
/// Reload the `ghci` session when files with these extensions are changed, in addition to
/// Haskell source files.
pub extra_extensions: Vec<String>,
}

impl GhciOpts {
Expand All @@ -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(),
})
}
}
Expand Down Expand Up @@ -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);
}
}
}
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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?;
Expand Down
4 changes: 2 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
17 changes: 17 additions & 0 deletions src/normal_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Path>) -> miette::Result<Self> {
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()
Expand Down Expand Up @@ -103,6 +114,12 @@ impl AsRef<Utf8Path> for NormalPath {
}
}

impl AsRef<Path> for NormalPath {
fn as_ref(&self) -> &Path {
self.normal.as_std_path()
}
}

impl Borrow<Utf8PathBuf> for NormalPath {
fn borrow(&self) -> &Utf8PathBuf {
&self.normal
Expand Down
Loading

0 comments on commit 7c2202a

Please sign in to comment.