Skip to content

Commit bf25327

Browse files
authored
Don't restart for removed or renamed modules (#290)
Ghcid crashes when modules are removed/renamed because all it can do to the underlying GHCi session is `:reload`, and [a GHCi bug][1] means that the GHCi session will crash when this happens. Because ghciwatch tracks which files/modules are loaded in the session, it can accurately `:unadd` files from the session when they're removed on disk, without requiring the session be restared. This should speed up development. [1]: https://gitlab.haskell.org/ghc/ghc/-/issues/11596 I'm going to cut 1.0 with this while we're at it. - [x] Labeled the PR with `patch`, `minor`, or `major` to request a version bump when it's merged. - [x] Updated the user manual in `docs/`. - [x] Added integration / regression tests in `tests/`.
1 parent a8a349d commit bf25327

15 files changed

Lines changed: 265 additions & 160 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ when source files change.
2121

2222
- GHCi output is displayed to the user as soon as it's printed.
2323
- Ghciwatch can handle new modules, removed modules, or moved modules without a
24-
hitch, so you don't need to manually restart it.
24+
hitch
2525
- A variety of [lifecycle
2626
hooks](https://mercurytechnologies.github.io/ghciwatch/lifecycle-hooks.html)
2727
let you run Haskell code or shell commands on a variety of events.

docs/introduction.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ when source files change.
99

1010
- GHCi output is displayed to the user as soon as it's printed.
1111
- Ghciwatch can handle new modules, removed modules, or moved modules without a
12+
hitch
1213
- A variety of [lifecycle hooks](lifecycle-hooks.md) let you run Haskell code
1314
or shell commands on a variety of events.
1415
- Run a test suite with [`--test-ghci
@@ -20,7 +21,6 @@ when source files change.
2021
- [Custom globs](cli.md#--reload-glob) can be supplied to reload or restart the
2122
GHCi session when non-Haskell files (like templates or database schema
2223
definitions) change.
23-
hitch, so you don't need to manually restart it.
2424
- Ghciwatch can [clear the screen between reloads](cli.md#--clear).
2525
- Compilation errors can be written to a file with [`--error-file`](cli.md#--error-file), for
2626
compatibility with [ghcid's][ghcid] `--outputfile` option.

src/cli.rs

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,15 @@ use crate::normal_path::NormalPath;
5555
override_usage = "ghciwatch [--command SHELL_COMMAND] [--watch PATH] [OPTIONS ...]"
5656
)]
5757
pub struct Opts {
58-
/// A shell command which starts a `ghci` REPL, e.g. `ghci` or `cabal v2-repl` or similar.
58+
/// A shell command which starts a GHCi REPL, e.g. `ghci` or `cabal v2-repl` or similar.
5959
///
60-
/// This is used to launch the underlying `ghci` session that `ghciwatch` controls.
60+
/// This is used to launch the underlying GHCi session that `ghciwatch` controls.
6161
///
6262
/// May contain quoted arguments which will be parsed in a `sh`-like manner.
6363
#[arg(long, value_name = "SHELL_COMMAND")]
6464
pub command: Option<ClonableCommand>,
6565

66-
/// A Haskell source file to load into a `ghci` REPL.
66+
/// A Haskell source file to load into a GHCi REPL.
6767
///
6868
/// Shortcut for `--command 'ghci PATH'`. Conflicts with `--command`.
6969
#[arg(value_name = "FILE", conflicts_with = "command")]
@@ -154,7 +154,7 @@ pub struct WatchOpts {
154154
#[arg(long = "watch", value_name = "PATH")]
155155
pub paths: Vec<NormalPath>,
156156

157-
/// Reload the `ghci` session when paths matching this glob change.
157+
/// Reload the GHCi session when paths matching this glob change.
158158
///
159159
/// By default, only changes to Haskell source files trigger reloads. If you'd like to exclude
160160
/// some files from that, you can add an ignore glob here, like `!src/my-special-dir/**/*.hs`.
@@ -169,13 +169,9 @@ pub struct WatchOpts {
169169
#[arg(long = "reload-glob")]
170170
pub reload_globs: Vec<String>,
171171

172-
/// Restart the `ghci` session when paths matching this glob change.
172+
/// Restart the GHCi session when paths matching this glob change.
173173
///
174-
/// By default, only changes to `.cabal` or `.ghci` files or Haskell source files being
175-
/// moved/removed will trigger restarts.
176-
///
177-
/// Due to [a `ghci` bug][1], the `ghci` session must be restarted when Haskell modules are removed
178-
/// or renamed.
174+
/// By default, only changes to `.cabal` or `.ghci` files will trigger restarts.
179175
///
180176
/// See `--reload-globs` for more details.
181177
///

src/ghci/mod.rs

Lines changed: 84 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ impl Ghci {
379379
let mut needs_restart = Vec::new();
380380
let mut needs_reload = Vec::new();
381381
let mut needs_add = Vec::new();
382+
let mut needs_remove = Vec::new();
382383
for event in events {
383384
let path = event.as_path();
384385
let path = self.relative_path(path)?;
@@ -395,7 +396,7 @@ impl Ghci {
395396
);
396397

397398
// Don't restart if we've explicitly ignored this path in a glob.
398-
if (!restart_match.is_ignore()
399+
if !restart_match.is_ignore()
399400
// Restart on `.cabal` and `.ghci` files.
400401
&& (path
401402
.extension()
@@ -406,31 +407,21 @@ impl Ghci {
406407
.map(|name| name == ".ghci")
407408
.unwrap_or(false)
408409
// Restart on explicit restart globs.
409-
|| restart_match.is_whitelist()))
410-
// Even if we've explicitly ignored this path in a glob, `ghci` can't cope with
411-
// removed modules, so we need to restart when modules are removed or renamed.
412-
//
413-
// See: https://gitlab.haskell.org/ghc/ghc/-/issues/11596
414-
//
415-
// TODO: I should investigate if `:unadd` works for some classes of removed
416-
// modules.
417-
|| (matches!(event, FileEvent::Remove(_))
418-
&& path_is_haskell_source_file
419-
&& self.targets.contains_source_path(&path))
410+
|| restart_match.is_whitelist())
420411
{
421412
// Restart for this path.
422413
tracing::debug!(%path, "Needs restart");
423414
needs_restart.push(path);
424-
} else if reload_match.is_whitelist() {
425-
// Extra extensions are always reloaded, never added.
426-
tracing::debug!(%path, "Needs reload");
427-
needs_reload.push(path);
428-
} else if !reload_match.is_ignore()
429-
// Don't reload if we've explicitly ignored this path in a glob.
430-
// Otherwise, reload when Haskell files are modified.
431-
&& matches!(event, FileEvent::Modify(_))
415+
} else if reload_match.is_ignore() {
416+
// Ignoring this path, continue.
417+
} else if matches!(event, FileEvent::Remove(_))
432418
&& path_is_haskell_source_file
419+
&& self.targets.contains_source_path(&path)
433420
{
421+
tracing::debug!(%path, "Needs remove");
422+
needs_remove.push(path);
423+
} else if matches!(event, FileEvent::Modify(_)) && path_is_haskell_source_file {
424+
// Otherwise, reload when Haskell files are modified.
434425
if self.targets.contains_source_path(&path) {
435426
// We can `:reload` paths in the target set.
436427
tracing::debug!(%path, "Needs reload");
@@ -440,13 +431,18 @@ impl Ghci {
440431
tracing::debug!(%path, "Needs add");
441432
needs_add.push(path);
442433
}
434+
} else if reload_match.is_whitelist() {
435+
// Extra extensions are always reloaded, never added.
436+
tracing::debug!(%path, "Needs reload");
437+
needs_reload.push(path);
443438
}
444439
}
445440

446441
Ok(ReloadActions {
447442
needs_restart,
448443
needs_reload,
449444
needs_add,
445+
needs_remove,
450446
})
451447
}
452448

@@ -480,20 +476,26 @@ impl Ghci {
480476

481477
let mut log = CompilationLog::default();
482478

483-
if actions.needs_add_or_reload() {
479+
if actions.needs_modify() {
484480
self.opts.clear();
485481
self.run_hooks(LifecycleEvent::Reload(hooks::When::Before), &mut log)
486482
.await?;
487483
}
488484

485+
if !actions.needs_remove.is_empty() {
486+
tracing::info!(
487+
"Removing modules from ghci:\n{}",
488+
format_bulleted_list(&actions.needs_remove)
489+
);
490+
self.remove_modules(&actions.needs_remove, &mut log).await?;
491+
}
492+
489493
if !actions.needs_add.is_empty() {
490494
tracing::info!(
491495
"Adding modules to ghci:\n{}",
492496
format_bulleted_list(&actions.needs_add)
493497
);
494-
for path in &actions.needs_add {
495-
self.add_module(path, &mut log).await?;
496-
}
498+
self.add_modules(&actions.needs_add, &mut log).await?;
497499
}
498500

499501
if !actions.needs_reload.is_empty() {
@@ -506,7 +508,7 @@ impl Ghci {
506508
.await?;
507509
}
508510

509-
if actions.needs_add_or_reload() {
511+
if actions.needs_modify() {
510512
self.finish_compilation(
511513
start_instant,
512514
&mut log,
@@ -641,6 +643,21 @@ impl Ghci {
641643
Ok(())
642644
}
643645

646+
/// Remove all `eval_commands` for the given paths.
647+
#[instrument(skip_all, level = "debug")]
648+
async fn clear_eval_commands_for_paths(
649+
&mut self,
650+
paths: impl IntoIterator<Item = impl Borrow<NormalPath>>,
651+
) {
652+
if !self.opts.enable_eval {
653+
return;
654+
}
655+
656+
for path in paths {
657+
self.eval_commands.remove(path.borrow());
658+
}
659+
}
660+
644661
/// Read and parse eval commands from the given `path`.
645662
#[instrument(level = "trace")]
646663
async fn parse_eval_commands(path: &Utf8Path) -> miette::Result<Vec<EvalCommand>> {
@@ -653,38 +670,32 @@ impl Ghci {
653670
Ok(commands)
654671
}
655672

656-
/// `:add` a module to the `ghci` session by path.
657-
///
658-
/// Optionally returns a compilation result.
673+
/// `:add` a module or modules to the `ghci` session by path.
659674
#[instrument(skip(self), level = "debug")]
660-
async fn add_module(
675+
async fn add_modules(
661676
&mut self,
662-
path: &NormalPath,
677+
paths: &[NormalPath],
663678
log: &mut CompilationLog,
664679
) -> miette::Result<()> {
665-
if self.targets.contains_source_path(path.absolute()) {
666-
tracing::debug!(%path, "Skipping `:add`ing already-loaded path");
667-
return Ok(());
668-
}
680+
let modules = self.targets.format_modules(&self.search_paths, paths)?;
669681

670682
self.stdin
671-
.add_module(&mut self.stdout, path.relative(), log)
683+
.add_modules(&mut self.stdout, &modules, log)
672684
.await?;
673685

674-
self.targets
675-
.insert_source_path(path.clone(), TargetKind::Path);
686+
for path in paths {
687+
self.targets
688+
.insert_source_path(path.clone(), TargetKind::Path);
689+
}
676690

677-
self.refresh_eval_commands_for_paths(std::iter::once(path))
678-
.await?;
691+
self.refresh_eval_commands_for_paths(paths).await?;
679692

680693
Ok(())
681694
}
682695

683696
/// `:add *` a module to the `ghci` session by path.
684697
///
685698
/// This forces it to be interpreted.
686-
///
687-
/// Optionally returns a compilation result.
688699
#[instrument(skip(self), level = "debug")]
689700
async fn interpret_module(
690701
&mut self,
@@ -707,6 +718,31 @@ impl Ghci {
707718
Ok(())
708719
}
709720

721+
/// `:unadd` a module or modules from the `ghci` session by path.
722+
#[instrument(skip(self), level = "debug")]
723+
async fn remove_modules(
724+
&mut self,
725+
paths: &[NormalPath],
726+
log: &mut CompilationLog,
727+
) -> miette::Result<()> {
728+
// Each `:unadd` implicitly reloads as well, so we have to `:unadd` all the modules in a
729+
// single command so that GHCi doesn't try to load a bunch of removed modules after each
730+
// one.
731+
let modules = self.targets.format_modules(&self.search_paths, paths)?;
732+
733+
self.stdin
734+
.remove_modules(&mut self.stdout, &modules, log)
735+
.await?;
736+
737+
for path in paths {
738+
self.targets.remove_source_path(path);
739+
self.clear_eval_commands_for_paths(std::iter::once(path))
740+
.await;
741+
}
742+
743+
Ok(())
744+
}
745+
710746
/// Stop this `ghci` session and cancel the async tasks associated with it.
711747
#[instrument(skip_all, level = "debug")]
712748
async fn stop(&mut self) -> miette::Result<()> {
@@ -851,12 +887,14 @@ struct ReloadActions {
851887
needs_reload: Vec<NormalPath>,
852888
/// Paths to modules which need an `:add`.
853889
needs_add: Vec<NormalPath>,
890+
/// Paths to modules which need an `:unadd`.
891+
needs_remove: Vec<NormalPath>,
854892
}
855893

856894
impl ReloadActions {
857-
/// Do any modules need to be added or reloaded?
858-
fn needs_add_or_reload(&self) -> bool {
859-
!self.needs_add.is_empty() || !self.needs_reload.is_empty()
895+
/// Do any modules need to be added, removed, or reloaded?
896+
fn needs_modify(&self) -> bool {
897+
!self.needs_add.is_empty() || !self.needs_reload.is_empty() || !self.needs_remove.is_empty()
860898
}
861899

862900
/// Is a session restart needed?
@@ -868,7 +906,7 @@ impl ReloadActions {
868906
fn kind(&self) -> GhciReloadKind {
869907
if self.needs_restart() {
870908
GhciReloadKind::Restart
871-
} else if self.needs_add_or_reload() {
909+
} else if self.needs_modify() {
872910
GhciReloadKind::Reload
873911
} else {
874912
GhciReloadKind::None
@@ -881,7 +919,7 @@ impl ReloadActions {
881919
pub enum GhciReloadKind {
882920
/// Noop. No actions needed.
883921
None,
884-
/// Reload and/or add modules. Can be interrupted.
922+
/// Reload, add, and/or remove modules. Can be interrupted.
885923
Reload,
886924
/// Restart the whole session. Cannot be interrupted.
887925
Restart,

src/ghci/parse/module_set.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::borrow::Borrow;
22
use std::cmp::Eq;
33
use std::collections::hash_map::Keys;
44
use std::collections::HashMap;
5+
use std::fmt::Display;
56
use std::hash::Hash;
67
use std::path::Path;
78

@@ -61,6 +62,17 @@ impl ModuleSet {
6162
}
6263
}
6364

65+
/// Remove a source path from this module set.
66+
///
67+
/// Returns the target's kind, if it was present in the set.
68+
pub fn remove_source_path<P>(&mut self, path: &P) -> Option<TargetKind>
69+
where
70+
NormalPath: Borrow<P>,
71+
P: Hash + Eq + ?Sized,
72+
{
73+
self.modules.remove(path)
74+
}
75+
6476
/// Get the name used to refer to the given module path when importing it.
6577
///
6678
/// If the module isn't imported, a path will be returned.
@@ -99,13 +111,32 @@ impl ModuleSet {
99111
}
100112
}
101113

114+
/// Format modules for adding or removing from a GHCi session.
115+
///
116+
/// See [`ModuleSet::module_import_name`].
117+
pub fn format_modules(
118+
&self,
119+
show_paths: &ShowPaths,
120+
modules: &[NormalPath],
121+
) -> miette::Result<String> {
122+
modules
123+
.iter()
124+
.map(|path| {
125+
self.module_import_name(show_paths, path)
126+
.map(|module| module.name)
127+
})
128+
.collect::<Result<Vec<_>, _>>()
129+
.map(|modules| modules.join(" "))
130+
}
131+
102132
/// Iterate over the source paths in this module set.
103133
pub fn iter(&self) -> Keys<'_, NormalPath, TargetKind> {
104134
self.modules.keys()
105135
}
106136
}
107137

108138
/// Information about a module to be imported into a `ghci` session.
139+
#[derive(Debug, Clone)]
109140
pub struct ImportInfo {
110141
/// The name to refer to the module by.
111142
///
@@ -117,3 +148,9 @@ pub struct ImportInfo {
117148
/// Whether the module is already loaded in the `ghci` session.
118149
pub loaded: bool,
119150
}
151+
152+
impl Display for ImportInfo {
153+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154+
write!(f, "{}", self.name)
155+
}
156+
}

0 commit comments

Comments
 (0)