Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Store lifecycle hooks dynamically #162

Merged
merged 2 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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