Skip to content

Commit dd8b335

Browse files
authored
Support async: commands (#141)
This lets you prefix shell commands with `async:` to have them run asynchronously. This will let us do things like reindex hie files in the background.
1 parent af07647 commit dd8b335

9 files changed

Lines changed: 353 additions & 42 deletions

File tree

src/cli.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use crate::clap::RustBacktrace;
1313
use crate::clonable_command::ClonableCommand;
1414
use crate::ghci::GhciCommand;
1515
use crate::ignore::GlobMatcher;
16+
use crate::maybe_async_command::MaybeAsyncCommand;
1617
use crate::normal_path::NormalPath;
1718

1819
/// A `ghci`-based file watcher and Haskell recompiler.
@@ -178,7 +179,7 @@ pub struct HookOpts {
178179
/// This can be used to regenerate `.cabal` files with `hpack`.
179180
/// Can be given multiple times.
180181
#[arg(long, value_name = "SHELL_COMMAND")]
181-
pub before_startup_shell: Vec<ClonableCommand>,
182+
pub before_startup_shell: Vec<MaybeAsyncCommand>,
182183

183184
/// `ghci` commands to run on startup. Use `:set args ...` in combination with `--test` to set
184185
/// the command-line arguments for tests.
@@ -199,6 +200,11 @@ pub struct HookOpts {
199200
#[arg(long, value_name = "GHCI_COMMAND")]
200201
pub after_reload_ghci: Vec<GhciCommand>,
201202

203+
/// Shell commands to run after reloading `ghci`.
204+
/// Can be given multiple times.
205+
#[arg(long, value_name = "SHELL_COMMAND")]
206+
pub after_reload_shell: Vec<MaybeAsyncCommand>,
207+
202208
/// `ghci` commands to run before restarting `ghci`.
203209
///
204210
/// See `--after-restart-ghci` for more details.
@@ -217,6 +223,11 @@ pub struct HookOpts {
217223
/// See: https://gitlab.haskell.org/ghc/ghc/-/issues/9648
218224
#[arg(long, value_name = "GHCI_COMMAND")]
219225
pub after_restart_ghci: Vec<GhciCommand>,
226+
227+
/// Shell commands to run after restarting `ghci`.
228+
/// Can be given multiple times.
229+
#[arg(long, value_name = "SHELL_COMMAND")]
230+
pub after_restart_shell: Vec<MaybeAsyncCommand>,
220231
}
221232

222233
impl Opts {

src/clonable_command.rs

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::collections::HashMap;
22
use std::ffi::OsString;
3+
use std::fmt::Display;
34
use std::path::PathBuf;
45
use std::process::Command as StdCommand;
56
use std::process::Stdio;
@@ -13,10 +14,12 @@ use miette::Context;
1314
use miette::IntoDiagnostic;
1415
use tokio::process::Command;
1516

17+
use crate::command_ext::CommandExt;
18+
1619
/// Like [`std::process::Stdio`], but it implements [`Clone`].
1720
///
1821
/// Unlike [`Stdio`], this value can't represent arbitrary files or file descriptors.
19-
#[derive(Debug, Clone, Copy)]
22+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2023
pub enum ClonableStdio {
2124
/// The stream will be ignored. Equivalent to attaching the stream to `/dev/null`.
2225
Null,
@@ -50,7 +53,7 @@ impl ClonableStdio {
5053
}
5154

5255
/// Like [`std::process::Command`], but it implements [`Clone`].
53-
#[derive(Debug, Clone)]
56+
#[derive(Debug, Clone, PartialEq, Eq)]
5457
pub struct ClonableCommand {
5558
/// The program to be executed.
5659
pub program: OsString,
@@ -93,6 +96,14 @@ impl ClonableCommand {
9396
self
9497
}
9598

99+
/// Add arguments to this command. See [`StdCommand::args`].
100+
pub fn args(mut self, args: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
101+
for arg in args {
102+
self = self.arg(arg);
103+
}
104+
self
105+
}
106+
96107
/// Create a new [`std::process::Command`] from this command's configuration.
97108
pub fn as_std(&self) -> StdCommand {
98109
let mut ret = StdCommand::new(&self.program);
@@ -142,7 +153,7 @@ impl FromStr for ClonableCommand {
142153
type Err = miette::Report;
143154

144155
fn from_str(shell_command: &str) -> Result<Self, Self::Err> {
145-
let tokens = shell_words::split(shell_command)
156+
let tokens = shell_words::split(shell_command.trim())
146157
.into_diagnostic()
147158
.wrap_err_with(|| format!("Failed to split shell command: {shell_command:?}"))?;
148159

@@ -161,6 +172,21 @@ impl FromStr for ClonableCommand {
161172
}
162173
}
163174

175+
impl Display for ClonableCommand {
176+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177+
let tokens = std::iter::once(self.program.to_string_lossy())
178+
.chain(self.args.iter().map(|arg| arg.to_string_lossy()));
179+
180+
write!(f, "{}", shell_words::join(tokens))
181+
}
182+
}
183+
184+
impl CommandExt for ClonableCommand {
185+
fn display(&self) -> String {
186+
self.to_string()
187+
}
188+
}
189+
164190
/// [`clap`] parser for [`ClonableCommand`] values.
165191
#[derive(Default, Clone)]
166192
pub struct ClonableCommandParser {
@@ -190,3 +216,25 @@ impl ValueParserFactory for ClonableCommand {
190216
Self::Parser::default()
191217
}
192218
}
219+
220+
#[cfg(test)]
221+
mod tests {
222+
use super::*;
223+
224+
#[test]
225+
fn test_parse() {
226+
// Note quotes, whitespace at both ends.
227+
assert_eq!(
228+
" puppy --flavor 'sammy' --eyes \"brown\" "
229+
.parse::<ClonableCommand>()
230+
.unwrap(),
231+
ClonableCommand::new("puppy").args(["--flavor", "sammy", "--eyes", "brown"])
232+
);
233+
234+
// But quoted whitespace is preserved.
235+
assert_eq!(
236+
" \" puppy\" ".parse::<ClonableCommand>().unwrap(),
237+
ClonableCommand::new(" puppy")
238+
);
239+
}
240+
}

src/command_ext.rs

Lines changed: 20 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ use std::process::Command as StdCommand;
22

33
use command_group::AsyncCommandGroup;
44
use command_group::AsyncGroupChild;
5-
use command_group::CommandGroup;
6-
use command_group::GroupChild;
75
use miette::Context;
86
use miette::IntoDiagnostic;
97
use nix::sys::signal::pthread_sigmask;
@@ -14,45 +12,19 @@ use tokio::process::Command;
1412

1513
/// Extension trait for commands.
1614
pub trait CommandExt {
17-
/// The type of spawned processes.
18-
type Child;
19-
20-
/// Spawn the command, but do not inherit `SIGINT` signals from the calling process.
21-
fn spawn_group_without_inheriting_sigint(&mut self) -> miette::Result<Self::Child>;
22-
2315
/// Display the command as a string, suitable for user output.
2416
///
2517
/// Arguments and program names should be quoted with [`shell_words::quote`].
2618
fn display(&self) -> String;
2719
}
2820

2921
impl CommandExt for Command {
30-
type Child = AsyncGroupChild;
31-
32-
fn spawn_group_without_inheriting_sigint(&mut self) -> miette::Result<Self::Child> {
33-
spawn_without_inheriting_sigint(|| {
34-
self.group_spawn()
35-
.into_diagnostic()
36-
.wrap_err_with(|| format!("Failed to start `{}`", self.display()))
37-
})
38-
}
39-
4022
fn display(&self) -> String {
4123
self.as_std().display()
4224
}
4325
}
4426

4527
impl CommandExt for StdCommand {
46-
type Child = GroupChild;
47-
48-
fn spawn_group_without_inheriting_sigint(&mut self) -> miette::Result<Self::Child> {
49-
spawn_without_inheriting_sigint(|| {
50-
self.group_spawn()
51-
.into_diagnostic()
52-
.wrap_err_with(|| format!("Failed to start `{}`", self.display()))
53-
})
54-
}
55-
5628
fn display(&self) -> String {
5729
let program = self.get_program().to_string_lossy();
5830

@@ -64,6 +36,26 @@ impl CommandExt for StdCommand {
6436
}
6537
}
6638

39+
pub trait SpawnExt {
40+
/// The type of spawned processes.
41+
type Child;
42+
43+
/// Spawn the command, but do not inherit `SIGINT` signals from the calling process.
44+
fn spawn_group_without_inheriting_sigint(&mut self) -> miette::Result<Self::Child>;
45+
}
46+
47+
impl SpawnExt for Command {
48+
type Child = AsyncGroupChild;
49+
50+
fn spawn_group_without_inheriting_sigint(&mut self) -> miette::Result<Self::Child> {
51+
spawn_without_inheriting_sigint(|| {
52+
self.group_spawn()
53+
.into_diagnostic()
54+
.wrap_err_with(|| format!("Failed to start `{}`", self.display()))
55+
})
56+
}
57+
}
58+
6759
fn spawn_without_inheriting_sigint<T>(
6860
spawn: impl FnOnce() -> miette::Result<T>,
6961
) -> miette::Result<T> {

src/ghci/mod.rs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ use std::collections::BTreeSet;
88
use std::fmt::Debug;
99
use std::fmt::Display;
1010
use std::path::Path;
11+
use std::process::ExitStatus;
1112
use std::process::Stdio;
1213
use std::time::Instant;
14+
use tokio::task::JoinHandle;
1315

1416
use aho_corasick::AhoCorasick;
1517
use camino::Utf8Path;
@@ -57,6 +59,7 @@ use crate::buffers::LINE_BUFFER_CAPACITY;
5759
use crate::cli::HookOpts;
5860
use crate::cli::Opts;
5961
use crate::clonable_command::ClonableCommand;
62+
use crate::command_ext::SpawnExt;
6063
use crate::event_filter::FileEvent;
6164
use crate::format_bulleted_list;
6265
use crate::haskell_source_file::is_haskell_source_file;
@@ -157,6 +160,8 @@ pub struct Ghci {
157160
eval_commands: BTreeMap<NormalPath, Vec<EvalCommand>>,
158161
/// Search paths / current working directory for this `ghci` session.
159162
search_paths: ShowPaths,
163+
/// Tasks running `async:` shell commands in the background.
164+
command_handles: Vec<JoinHandle<miette::Result<ExitStatus>>>,
160165
}
161166

162167
impl Debug for Ghci {
@@ -174,9 +179,14 @@ impl Ghci {
174179
/// streams.
175180
#[instrument(skip_all, level = "debug", name = "ghci")]
176181
pub async fn new(mut shutdown: ShutdownHandle, opts: GhciOpts) -> miette::Result<Self> {
177-
for command in &opts.hooks.before_startup_shell {
178-
shutdown.error_if_shutdown_requested()?;
179-
Self::before_startup_shell(command).await?;
182+
let mut command_handles = Vec::new();
183+
{
184+
let span = tracing::debug_span!("before_startup_shell");
185+
let _enter = span.enter();
186+
for command in &opts.hooks.before_startup_shell {
187+
tracing::info!(%command, "Running before-startup command");
188+
command.run_on(&mut command_handles).await?;
189+
}
180190
}
181191

182192
let mut group = {
@@ -271,6 +281,7 @@ impl Ghci {
271281
cwd: crate::current_dir_utf8()?,
272282
search_paths: Default::default(),
273283
},
284+
command_handles,
274285
})
275286
}
276287

@@ -410,6 +421,10 @@ impl Ghci {
410421
tracing::info!(%command, "Running after-restart command");
411422
self.stdin.run_command(&mut self.stdout, command).await?;
412423
}
424+
for command in &self.opts.hooks.after_restart_shell {
425+
tracing::info!(%command, "Running after-restart command");
426+
command.run_on(&mut self.command_handles).await?;
427+
}
413428
// Once we restart, everything is freshly loaded. We don't need to add or
414429
// reload any other modules.
415430
return Ok(());
@@ -455,6 +470,10 @@ impl Ghci {
455470
tracing::info!(%command, "Running after-reload command");
456471
self.stdin.run_command(&mut self.stdout, command).await?;
457472
}
473+
for command in &self.opts.hooks.after_reload_shell {
474+
tracing::info!(%command, "Running after-reload command");
475+
command.run_on(&mut self.command_handles).await?;
476+
}
458477

459478
if compilation_failed {
460479
tracing::debug!("Compilation failed, skipping running tests.");
@@ -465,6 +484,9 @@ impl Ghci {
465484
}
466485
}
467486

487+
// Get rid of any handles for background commands that have finished.
488+
self.command_handles.retain(|handle| !handle.is_finished());
489+
468490
Ok(())
469491
}
470492

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ mod ghci;
2525
mod haskell_source_file;
2626
mod ignore;
2727
mod incremental_reader;
28+
mod maybe_async_command;
2829
mod normal_path;
2930
mod shutdown;
3031
mod tracing;

0 commit comments

Comments
 (0)