ghciwatch/
hooks.rs

1//! Lifecycle hooks.
2
3use std::fmt::Display;
4use std::fmt::Write;
5use std::process::ExitStatus;
6use std::str::FromStr;
7
8use clap::builder::ValueParserFactory;
9use clap::Arg;
10use clap::ArgAction;
11use clap::Args;
12use clap::FromArgMatches;
13use enum_iterator::Sequence;
14use indoc::indoc;
15use tokio::task::JoinHandle;
16
17use crate::ghci::GhciCommand;
18use crate::maybe_async_command::MaybeAsyncCommand;
19
20/// A lifecycle event that triggers hooks.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Sequence)]
22pub enum LifecycleEvent {
23    /// When tests are run (after startup, after reloads).
24    Test,
25    /// When a `ghci` session is started (at `ghciwatch` startup and after restarts).
26    Startup(When),
27    /// When a module is changed or added.
28    Reload(When),
29    /// When a `ghci` session is restarted (when a module is removed or renamed).
30    Restart(When),
31}
32
33impl Display for LifecycleEvent {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        if let Some(when) = self.when() {
36            write!(f, "{}-", when)?;
37        }
38        write!(f, "{}", self.event_name())
39    }
40}
41
42impl LifecycleEvent {
43    /// Get the event name, like `test` or `reload`.
44    pub fn event_name(&self) -> &'static str {
45        match self {
46            LifecycleEvent::Test => "test",
47            LifecycleEvent::Startup(_) => "startup",
48            LifecycleEvent::Reload(_) => "reload",
49            LifecycleEvent::Restart(_) => "restart",
50        }
51    }
52
53    /// Get the noun form of the event name, like `testing` or `reloading`.
54    pub fn event_noun(&self) -> &'static str {
55        match self {
56            LifecycleEvent::Test => "testing",
57            LifecycleEvent::Startup(_) => "starting up",
58            LifecycleEvent::Reload(_) => "reloading",
59            LifecycleEvent::Restart(_) => "restarting",
60        }
61    }
62
63    fn get_message(&self) -> &'static str {
64        match self {
65            LifecycleEvent::Test => indoc!(
66                "
67                Tests are run after startup and after reloads.
68                "
69            ),
70            LifecycleEvent::Startup(_) => indoc!(
71                "
72                Startup hooks run when GHCi is started (at `ghciwatch` startup and after GHCi restarts).
73                "
74            ),
75            LifecycleEvent::Reload(_) => indoc!(
76                "
77                Reload hooks are run when modules are changed on disk.
78                "
79            ),
80            LifecycleEvent::Restart(_) => indoc!(
81                "
82                The GHCi session must be restarted when `.cabal` or `.ghci` files are modified.
83                "
84            ),
85        }.trim_end_matches('\n')
86    }
87
88    fn get_help_name(&self) -> Option<&'static str> {
89        match self {
90            LifecycleEvent::Test => Some("tests"),
91            _ => None,
92        }
93    }
94
95    fn when(&self) -> Option<When> {
96        match &self {
97            LifecycleEvent::Test => None,
98            LifecycleEvent::Startup(when) => Some(*when),
99            LifecycleEvent::Reload(when) => Some(*when),
100            LifecycleEvent::Restart(when) => Some(*when),
101        }
102    }
103
104    fn supported_kind(&self) -> Vec<CommandKind> {
105        match self {
106            LifecycleEvent::Startup(When::Before) => vec![CommandKind::Shell],
107            LifecycleEvent::Startup(When::After)
108            | LifecycleEvent::Test
109            | LifecycleEvent::Reload(_)
110            | LifecycleEvent::Restart(_) => {
111                vec![CommandKind::Ghci, CommandKind::Shell]
112            }
113        }
114    }
115
116    fn hooks() -> impl Iterator<Item = Hook<CommandKind>> {
117        enum_iterator::all::<Self>().flat_map(|event| {
118            event.supported_kind().into_iter().map(move |kind| Hook {
119                event,
120                command: kind,
121            })
122        })
123    }
124}
125
126/// When to run a hook in relation to a given [`LifecycleEvent`].
127#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Sequence)]
128pub enum When {
129    /// Run the hook before the event.
130    Before,
131    /// Run the hook after the event.
132    After,
133}
134
135impl Display for When {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        match self {
138            When::Before => write!(f, "before"),
139            When::After => write!(f, "after"),
140        }
141    }
142}
143
144/// The kind of hook; a `ghci` command or a shell command.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Sequence)]
146pub enum CommandKind {
147    /// A shell command.
148    Shell,
149    /// A `ghci` command.
150    ///
151    /// Can either be Haskell code to run (`TestMain.test`) or a `ghci` command (`:set args ...`).
152    Ghci,
153}
154
155impl Display for CommandKind {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        match self {
158            CommandKind::Shell => write!(f, "shell"),
159            CommandKind::Ghci => write!(f, "ghci"),
160        }
161    }
162}
163
164impl CommandKind {
165    fn placeholder_name(&self) -> &'static str {
166        match self {
167            CommandKind::Ghci => "GHCI_CMD",
168            CommandKind::Shell => "SHELL_CMD",
169        }
170    }
171}
172
173/// A command to run for a hook.
174#[derive(Debug, Clone)]
175pub enum Command {
176    /// A shell command.
177    Shell(MaybeAsyncCommand),
178    /// A `ghci` command.
179    Ghci(GhciCommand),
180}
181
182impl Command {
183    fn kind(&self) -> CommandKind {
184        match self {
185            Command::Ghci(_) => CommandKind::Ghci,
186            Command::Shell(_) => CommandKind::Shell,
187        }
188    }
189}
190
191impl Display for Command {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        match self {
194            Command::Ghci(command) => command.fmt(f),
195            Command::Shell(command) => command.fmt(f),
196        }
197    }
198}
199
200/// A lifecycle hook, specifying a command to run and an event to run it at.
201#[derive(Debug, Clone)]
202pub struct Hook<C> {
203    /// The event to run this hook on.
204    pub event: LifecycleEvent,
205    /// The command to run.
206    pub command: C,
207}
208
209impl<C> Display for Hook<C> {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        self.event.fmt(f)
212    }
213}
214
215impl<C> Hook<C> {
216    fn with_command<C2>(&self, command: C2) -> Hook<C2> {
217        Hook {
218            event: self.event,
219            command,
220        }
221    }
222}
223
224impl Hook<CommandKind> {
225    fn extra_help(&self) -> Option<&'static str> {
226        match (self.event, self.command) {
227            (LifecycleEvent::Startup(When::Before), _) => Some(indoc!(
228                "
229                This can be used to regenerate `.cabal` files with `hpack`.
230                ",
231            )),
232            (LifecycleEvent::Startup(When::After), CommandKind::Ghci) => Some(indoc!(
233                "
234                Use `:set args ...` to set command-line arguments for test hooks.
235                ",
236            )),
237            (LifecycleEvent::Test, CommandKind::Ghci) => Some(indoc!(
238                "
239                Example: `TestMain.testMain`.
240                ",
241            )),
242            _ => None,
243        }
244        .map(|help| help.trim_end_matches('\n'))
245    }
246
247    fn arg_name(&self) -> String {
248        format!("{}-{}", self.event, self.command)
249    }
250
251    fn help(&self) -> Help {
252        let Hook { event, command } = self;
253        let kind = match command {
254            CommandKind::Ghci => "`ghci`",
255            CommandKind::Shell => "Shell",
256        };
257
258        let mut short = format!("{kind} commands to run");
259
260        if let Some(when) = self.event.when() {
261            short.push(' ');
262            write!(short, "{}", when).expect("Writing to a `String` never fails");
263        }
264
265        short.push(' ');
266        if let Some(help_name) = event.get_help_name() {
267            short.push_str(help_name);
268        } else {
269            write!(short, "{}", event.event_name()).expect("Writing to a `String` never fails");
270        }
271
272        let mut long = short.clone();
273
274        long.push_str("\n\n");
275        long.push_str(event.get_message());
276
277        if let CommandKind::Shell = command {
278            long.push_str("\n\nCommands starting with `async:` will be run in the background.");
279        }
280
281        if let Some(extra_help) = self.extra_help() {
282            long.push_str("\n\n");
283            long.push_str(extra_help);
284        }
285
286        long.push_str("\n\nCan be given multiple times.");
287
288        Help { short, long }
289    }
290}
291
292struct Help {
293    short: String,
294    long: String,
295}
296
297/// Lifecycle hooks.
298///
299/// These are `ghci` and shell commands to run at various points in the `ghciwatch`
300/// lifecycle.
301#[derive(Debug, Clone, Default)]
302pub struct HookOpts {
303    hooks: Vec<Hook<Command>>,
304}
305
306impl HookOpts {
307    pub fn select(&self, event: LifecycleEvent) -> impl Iterator<Item = &Hook<Command>> {
308        self.hooks.iter().filter(move |hook| hook.event == event)
309    }
310
311    pub async fn run_shell_hooks(
312        &self,
313        event: LifecycleEvent,
314        handles: &mut Vec<JoinHandle<miette::Result<ExitStatus>>>,
315    ) -> miette::Result<()> {
316        for hook in self.select(event) {
317            if let Command::Shell(command) = &hook.command {
318                tracing::info!(%command, "Running {hook} command");
319                command.run_on(handles).await?;
320            }
321        }
322        Ok(())
323    }
324}
325
326impl Args for HookOpts {
327    fn augment_args(mut cmd: clap::Command) -> clap::Command {
328        for hook in LifecycleEvent::hooks() {
329            let name = hook.arg_name();
330            let help = hook.help();
331            let arg = Arg::new(&name)
332                .long(&name)
333                .action(ArgAction::Append)
334                .required(false)
335                .value_name(hook.command.placeholder_name())
336                .help(help.short)
337                .long_help(help.long)
338                .help_heading("Lifecycle hooks");
339
340            let arg = match hook.command {
341                CommandKind::Ghci => arg.value_parser(GhciCommand::value_parser()),
342                CommandKind::Shell => arg.value_parser(MaybeAsyncCommand::from_str),
343            };
344
345            cmd = cmd.arg(arg);
346        }
347        cmd
348    }
349
350    fn augment_args_for_update(cmd: clap::Command) -> clap::Command {
351        Self::augment_args(cmd)
352    }
353}
354
355impl FromArgMatches for HookOpts {
356    fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
357        let mut ret = Self::default();
358        ret.update_from_arg_matches(matches)?;
359        Ok(ret)
360    }
361
362    fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
363        for hook in LifecycleEvent::hooks() {
364            let name = hook.arg_name();
365            match hook.command {
366                CommandKind::Ghci => {
367                    self.hooks.extend(
368                        matches
369                            .get_many::<GhciCommand>(&name)
370                            .into_iter()
371                            .flatten()
372                            .map(|command| hook.with_command(Command::Ghci(command.clone()))),
373                    );
374                }
375                CommandKind::Shell => {
376                    self.hooks.extend(
377                        matches
378                            .get_many::<MaybeAsyncCommand>(&name)
379                            .into_iter()
380                            .flatten()
381                            .map(|command| hook.with_command(Command::Shell(command.clone()))),
382                    );
383                }
384            }
385        }
386
387        // Sort the hooks so that shell commands are first.
388        //
389        // Shell commands _may_ be asynchronous, but `ghci` commands are always synchronous, so we
390        // run shell commands first.
391        self.hooks
392            .sort_by(|a, b| a.command.kind().cmp(&b.command.kind()));
393
394        Ok(())
395    }
396}