Skip to content

Commit

Permalink
Store lifecycle hooks dynamically (#162)
Browse files Browse the repository at this point in the history
Instead of having a field for each event (`before_restart_ghci`,
`test_ghci`, `after_startup_shell`, etc.), we generate and store the
arguments dynamically. This simplifies the running of hooks and will
hopefully make it easier to extend the lifecycle hooks in the future.
  • Loading branch information
9999years authored Nov 29, 2023
1 parent 96be385 commit 253d009
Show file tree
Hide file tree
Showing 8 changed files with 639 additions and 327 deletions.
366 changes: 205 additions & 161 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ categories = ["command-line-utilities", "development-tools"]
aho-corasick = "1.0.2"
backoff = { version = "0.4.0", default-features = false }
camino = "1.1.4"
clap = { version = "4.3.2", features = ["derive", "wrap_help", "env"] }
clap = { version = "4.3.2", features = ["derive", "wrap_help", "env", "string"] }
command-group = { version = "2.1.0", features = ["tokio", "with-tokio"] }
enum-iterator = "1.4.1"
humantime = "2.1.0"
ignore = "0.4.20"
indoc = "1.0.6"
itertools = "0.11.0"
line-span = "0.1.5"
miette = { version = "5.9.0", features = ["fancy"] }
Expand All @@ -58,12 +60,12 @@ tokio = { version = "1.28.2", features = ["full", "tracing"] }
tracing = "0.1.37"
tracing-human-layer = "0.1.0"
tracing-subscriber = { version = "0.3.17", features = ["env-filter", "time", "json", "registry"] }
unindent = "0.2.3"
winnow = "0.5.15"

[dev-dependencies]
test-harness = { path = "test-harness" }
expect-test = "1.4.0"
indoc = "1.0.6"
pretty_assertions = "1.2.1"
tracing-test = { version = "0.2", features = ["no-env-filter"] }

Expand Down
84 changes: 1 addition & 83 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ use tracing_subscriber::fmt::format::FmtSpan;
use crate::clap::FmtSpanParserFactory;
use crate::clap::RustBacktrace;
use crate::clonable_command::ClonableCommand;
use crate::ghci::GhciCommand;
use crate::ignore::GlobMatcher;
use crate::maybe_async_command::MaybeAsyncCommand;
use crate::normal_path::NormalPath;

/// A `ghci`-based file watcher and Haskell recompiler.
Expand Down Expand Up @@ -42,7 +40,7 @@ pub struct Opts {

/// Lifecycle hooks and commands to run at various points.
#[command(flatten)]
pub hooks: HookOpts,
pub hooks: crate::hooks::HookOpts,

/// Options to modify file watching.
#[command(flatten)]
Expand Down Expand Up @@ -161,86 +159,6 @@ pub struct LoggingOpts {
pub log_json: Option<Utf8PathBuf>,
}

/// Lifecycle hooks.
///
/// These are commands (mostly `ghci` commands) to run at various points in the `ghciwatch`
/// lifecycle.
#[derive(Debug, Clone, clap::Args)]
#[clap(next_help_heading = "Lifecycle hooks")]
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<MaybeAsyncCommand>,

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

/// Shell commands to run before reloading `ghci`.
///
/// Can be given multiple times.
#[arg(long, value_name = "SHELL_COMMAND")]
pub before_reload_shell: Vec<MaybeAsyncCommand>,

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

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

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

/// Shell commands to run before restarting `ghci`.
/// Can be given multiple times.
#[arg(long, value_name = "SHELL_COMMAND")]
pub before_restart_shell: Vec<MaybeAsyncCommand>,

/// `ghci` commands to run after restarting `ghci`.
/// Can be given multiple times.
///
/// `ghci` cannot reload after files are deleted due to a bug, so `ghciwatch` has to restart the
/// underlying `ghci` session when this happens. Note that the `--before-restart-ghci` and
/// `--after-restart-ghci` commands will therefore run in different `ghci` sessions without
/// shared context.
///
/// See: https://gitlab.haskell.org/ghc/ghc/-/issues/9648
#[arg(long, value_name = "GHCI_COMMAND")]
pub after_restart_ghci: Vec<GhciCommand>,

/// Shell commands to run after restarting `ghci`.
/// Can be given multiple times.
#[arg(long, value_name = "SHELL_COMMAND")]
pub after_restart_shell: Vec<MaybeAsyncCommand>,
}

impl Opts {
/// Perform late initialization of the command-line arguments. If `init` isn't called before
/// the arguments are used, the behavior is undefined.
Expand Down
87 changes: 43 additions & 44 deletions src/ghci/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,14 @@ pub use ghci_command::GhciCommand;

use crate::aho_corasick::AhoCorasickExt;
use crate::buffers::LINE_BUFFER_CAPACITY;
use crate::cli::HookOpts;
use crate::cli::Opts;
use crate::clonable_command::ClonableCommand;
use crate::event_filter::FileEvent;
use crate::format_bulleted_list;
use crate::haskell_source_file::is_haskell_source_file;
use crate::hooks;
use crate::hooks::HookOpts;
use crate::hooks::LifecycleEvent;
use crate::ignore::GlobMatcher;
use crate::incremental_reader::IncrementalReader;
use crate::normal_path::NormalPath;
Expand Down Expand Up @@ -179,10 +181,12 @@ impl Ghci {
{
let span = tracing::debug_span!("before_startup_shell");
let _enter = span.enter();
for command in &opts.hooks.before_startup_shell {
tracing::info!(%command, "Running before-startup command");
command.run_on(&mut command_handles).await?;
}
opts.hooks
.run_shell_hooks(
LifecycleEvent::Startup(hooks::When::Before),
&mut command_handles,
)
.await?;
}

let mut group = {
Expand Down Expand Up @@ -293,11 +297,10 @@ impl Ghci {
self.stdout.initialize().await?;

// Perform start-of-session initialization.
let messages = self
.stdin
.initialize(&mut self.stdout, &self.opts.hooks.after_startup_ghci)
.await?;
let messages = self.stdin.initialize(&mut self.stdout).await?;
self.process_ghc_messages(messages).await?;
self.run_hooks(LifecycleEvent::Startup(hooks::When::After))
.await?;

// Get the initial list of targets.
self.refresh_targets().await?;
Expand Down Expand Up @@ -423,14 +426,8 @@ impl Ghci {
}

if actions.needs_add_or_reload() {
for command in &self.opts.hooks.before_reload_shell {
tracing::info!(%command, "Running before-reload command");
command.run_on(&mut self.command_handles).await?;
}
for command in &self.opts.hooks.before_reload_ghci {
tracing::info!(%command, "Running before-reload command");
self.stdin.run_command(&mut self.stdout, command).await?;
}
self.run_hooks(LifecycleEvent::Reload(hooks::When::Before))
.await?;
}

let mut compilation_failed = false;
Expand Down Expand Up @@ -462,14 +459,8 @@ impl Ghci {
}

if actions.needs_add_or_reload() {
for command in &self.opts.hooks.after_reload_shell {
tracing::info!(%command, "Running after-reload command");
command.run_on(&mut self.command_handles).await?;
}
for command in &self.opts.hooks.after_reload_ghci {
tracing::info!(%command, "Running after-reload command");
self.stdin.run_command(&mut self.stdout, command).await?;
}
self.run_hooks(LifecycleEvent::Reload(hooks::When::After))
.await?;

if compilation_failed {
tracing::debug!("Compilation failed, skipping running tests.");
Expand All @@ -488,35 +479,22 @@ impl Ghci {
/// Restart the `ghci` session.
#[instrument(skip_all, level = "debug")]
async fn restart(&mut self) -> miette::Result<()> {
for command in &self.opts.hooks.before_restart_shell {
tracing::info!(%command, "Running before-restart command");
command.run_on(&mut self.command_handles).await?;
}
for command in &self.opts.hooks.before_restart_ghci {
tracing::info!(%command, "Running before-restart command");
self.stdin.run_command(&mut self.stdout, command).await?;
}
self.run_hooks(LifecycleEvent::Restart(hooks::When::Before))
.await?;
self.stop().await?;
let new = Self::new(self.shutdown.clone(), self.opts.clone()).await?;
let _ = std::mem::replace(self, new);
self.initialize().await?;
for command in &self.opts.hooks.after_restart_shell {
tracing::info!(%command, "Running after-restart command");
command.run_on(&mut self.command_handles).await?;
}
for command in &self.opts.hooks.after_restart_ghci {
tracing::info!(%command, "Running after-restart command");
self.stdin.run_command(&mut self.stdout, command).await?;
}
self.run_hooks(LifecycleEvent::Restart(hooks::When::After))
.await?;
Ok(())
}

/// Run the user provided test command.
#[instrument(skip_all, level = "debug")]
async fn test(&mut self) -> miette::Result<()> {
self.stdin
.test(&mut self.stdout, &self.opts.hooks.test_ghci)
.await?;
self.stdin.set_mode(&mut self.stdout, Mode::Testing).await?;
self.run_hooks(LifecycleEvent::Test).await?;
Ok(())
}

Expand Down Expand Up @@ -730,6 +708,27 @@ impl Ghci {
fn prune_command_handles(&mut self) {
self.command_handles.retain(|handle| !handle.is_finished());
}

#[instrument(skip(self), level = "trace")]
async fn run_hooks(&mut self, event: LifecycleEvent) -> miette::Result<()> {
for hook in self.opts.hooks.select(event) {
tracing::info!(command = %hook.command, "Running {hook} command");
match &hook.command {
hooks::Command::Ghci(command) => {
let start_time = Instant::now();
self.stdin.run_command(&mut self.stdout, command).await?;
if let LifecycleEvent::Test = &hook.event {
tracing::info!("Finished running tests in {:.2?}", start_time.elapsed());
}
}
hooks::Command::Shell(command) => {
command.run_on(&mut self.command_handles).await?;
}
}
}

Ok(())
}
}

/// The mode a `ghci` session is in. This is used to track output, particularly for the error log
Expand Down
4 changes: 2 additions & 2 deletions src/ghci/parse/haskell_grammar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//!
//! [1]: https://www.haskell.org/onlinereport/haskell2010/haskellch2.html

use winnow::combinator::separated1;
use winnow::combinator::separated;
use winnow::token::one_of;
use winnow::token::take_while;
use winnow::PResult;
Expand All @@ -15,7 +15,7 @@ use winnow::Parser;
/// See: `modid` in <https://www.haskell.org/onlinereport/haskell2010/haskellch2.html#x7-180002.4>
pub fn module_name<'i>(input: &mut &'i str) -> PResult<&'i str> {
// Surely there's a better way to get type inference to work here?
separated1::<_, _, (), _, _, _, _>(constructor_name, ".")
separated::<_, _, (), _, _, _, _>(1.., constructor_name, ".")
.recognize()
.parse_next(input)
}
Expand Down
36 changes: 1 addition & 35 deletions src/ghci/stdin.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
use std::time::Instant;

use camino::Utf8Path;
use miette::Context;
use miette::IntoDiagnostic;
Expand Down Expand Up @@ -83,11 +81,7 @@ impl GhciStdin {
}

#[instrument(skip(self, stdout), name = "stdin_initialize", level = "debug")]
pub async fn initialize(
&mut self,
stdout: &mut GhciStdout,
setup_commands: &[GhciCommand],
) -> miette::Result<Vec<GhcMessage>> {
pub async fn initialize(&mut self, stdout: &mut GhciStdout) -> miette::Result<Vec<GhcMessage>> {
// We tell stdout/stderr we're compiling for the first prompt because this includes all the
// module compilation before the first prompt.
self.set_mode(stdout, Mode::Compiling).await?;
Expand All @@ -98,11 +92,6 @@ impl GhciStdin {
self.write_line(stdout, &format!(":set prompt-cont {PROMPT}\n"))
.await?;

for command in setup_commands {
tracing::debug!(%command, "Running after-startup command");
self.run_command(stdout, command).await?;
}

Ok(messages)
}

Expand All @@ -112,29 +101,6 @@ impl GhciStdin {
self.write_line(stdout, ":reload\n").await
}

#[instrument(skip_all, level = "debug")]
pub async fn test(
&mut self,
stdout: &mut GhciStdout,
test_commands: &[GhciCommand],
) -> miette::Result<()> {
if test_commands.is_empty() {
tracing::debug!("No test command provided, not running tests");
return Ok(());
}

self.set_mode(stdout, Mode::Testing).await?;
for test_command in test_commands {
tracing::debug!(command = %test_command, "Running user test command");
tracing::info!("Running tests: {test_command}");
let start_time = Instant::now();
self.run_command(stdout, test_command).await?;
tracing::info!("Finished running tests in {:.2?}", start_time.elapsed());
}

Ok(())
}

#[instrument(skip(self, stdout), level = "debug")]
pub async fn add_module(
&mut self,
Expand Down
Loading

0 comments on commit 253d009

Please sign in to comment.