├── .envrc ├── docs ├── .gitignore ├── book.toml ├── SUMMARY.md ├── getting-started.md ├── install.md ├── integration │ ├── tasty.md │ └── multiple-components.md ├── faq.md ├── introduction.md ├── comment-evaluation.md └── no-load.md ├── .github ├── pinact.yml ├── labeler.yml ├── pull_request_template.md ├── ISSUE_TEMPLATE │ ├── feature_request.yml │ └── bug_report.yml └── workflows │ ├── label-issues.yaml │ ├── pages.yaml │ ├── label-prs.yaml │ └── version.yaml ├── .gitignore ├── tests ├── data │ └── simple │ │ ├── .gitignore │ │ ├── src │ │ ├── MyModule.hs │ │ └── MyLib.hs │ │ ├── test-main │ │ └── Main.hs │ │ ├── cabal.project │ │ ├── test │ │ └── TestMain.hs │ │ ├── Makefile │ │ └── package.yaml ├── dot_ghci.rs ├── remove.rs ├── load.rs ├── all_good.rs ├── failed_modules.rs ├── test.rs ├── clear.rs ├── rename.rs ├── shutdown.rs ├── reload.rs └── error_log.rs ├── nix ├── packages │ ├── allChecks.nix │ ├── get-crate-version.nix │ ├── checks │ │ ├── treefmt.nix │ │ └── haskell-project.nix │ ├── mkCheck.nix │ ├── checksFrom.nix │ ├── make-release-commit.nix │ └── cargo-llvm-cov.nix └── makePackages.nix ├── treefmt.toml ├── garnix.yaml ├── src ├── clap │ ├── mod.rs │ ├── error_message.rs │ ├── camino.rs │ ├── rust_backtrace.rs │ ├── fmt_span.rs │ └── humantime.rs ├── format_bulleted_list.rs ├── cwd.rs ├── command_ext.rs ├── ghci │ ├── parse │ │ ├── mod.rs │ │ ├── ghc_message │ │ │ ├── single_quote.rs │ │ │ ├── loaded_configuration.rs │ │ │ ├── path_colon.rs │ │ │ ├── cant_find_file_diagnostic.rs │ │ │ ├── severity.rs │ │ │ ├── compiling.rs │ │ │ ├── no_location_info_diagnostic.rs │ │ │ └── message_body.rs │ │ ├── show_targets.rs │ │ ├── haskell_grammar.rs │ │ └── lines.rs │ ├── ghci_command.rs │ ├── compilation_log.rs │ ├── error_log.rs │ ├── process.rs │ ├── loaded_module.rs │ ├── module_set.rs │ ├── writer.rs │ └── stderr.rs ├── string_case.rs ├── aho_corasick.rs ├── buffers.rs ├── haskell_source_file.rs ├── lib.rs ├── fake_reader.rs ├── clap_markdown │ └── mod.rs ├── event_filter.rs ├── main.rs ├── tui │ └── terminal.rs └── tracing │ └── mod.rs ├── test-harness-macro ├── Cargo.toml └── src │ └── lib.rs ├── test-harness ├── src │ ├── matcher │ │ ├── never_matcher.rs │ │ ├── into_matcher.rs │ │ ├── span_matcher.rs │ │ ├── or_matcher.rs │ │ ├── and_matcher.rs │ │ ├── fused_matcher.rs │ │ ├── mod.rs │ │ ├── negative_matcher.rs │ │ ├── field_matcher.rs │ │ └── option_matcher.rs │ ├── lib.rs │ ├── tracing_reader.rs │ ├── checkpoint.rs │ ├── ghc_version.rs │ └── tracing_json.rs └── Cargo.toml ├── justfile ├── LICENSE ├── .config └── nextest.toml ├── CONTRIBUTING.md ├── README.md ├── flake.lock ├── Cargo.toml └── flake.nix /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /book 2 | -------------------------------------------------------------------------------- /.github/pinact.yml: -------------------------------------------------------------------------------- 1 | version: 3 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /result* 3 | lcov.info 4 | -------------------------------------------------------------------------------- /tests/data/simple/.gitignore: -------------------------------------------------------------------------------- 1 | /my-simple-package.cabal 2 | /dist-newstyle 3 | -------------------------------------------------------------------------------- /tests/data/simple/src/MyModule.hs: -------------------------------------------------------------------------------- 1 | module MyModule (example) where 2 | 3 | example :: String 4 | example = "example" 5 | -------------------------------------------------------------------------------- /nix/packages/allChecks.nix: -------------------------------------------------------------------------------- 1 | { 2 | ghciwatch, 3 | checksFrom, 4 | checks, 5 | }: 6 | (checksFrom ghciwatch) // checks 7 | -------------------------------------------------------------------------------- /tests/data/simple/src/MyLib.hs: -------------------------------------------------------------------------------- 1 | module MyLib (someFunc) where 2 | 3 | someFunc :: IO () 4 | someFunc = putStrLn "someFunc" 5 | -------------------------------------------------------------------------------- /tests/data/simple/test-main/Main.hs: -------------------------------------------------------------------------------- 1 | module Main where 2 | 3 | import TestMain (testMain) 4 | 5 | main :: IO () 6 | main = testMain 7 | -------------------------------------------------------------------------------- /tests/data/simple/cabal.project: -------------------------------------------------------------------------------- 1 | packages: my-simple-package.cabal 2 | offline: True 3 | flags: +local-dev 4 | package * 5 | ghc-options: -fdiagnostics-color=always 6 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # See: https://github.com/marketplace/actions/labeler 3 | patch: 4 | - 'src/**/*.rs' 5 | - '**/Cargo.toml' 6 | - '**/Cargo.lock' 7 | - 'flake.nix' 8 | - 'flake.lock' 9 | -------------------------------------------------------------------------------- /tests/data/simple/test/TestMain.hs: -------------------------------------------------------------------------------- 1 | module TestMain (testMain) where 2 | 3 | import System.IO (hPutStrLn, stderr) 4 | 5 | testMain :: IO () 6 | testMain = hPutStrLn stderr "0 tests executed, 0 failures :)" 7 | -------------------------------------------------------------------------------- /treefmt.toml: -------------------------------------------------------------------------------- 1 | [formatter.nix] 2 | # TODO: Use nixfmt-rfc-style 3 | command = "alejandra" 4 | includes = ["*.nix"] 5 | 6 | [formatter.rust] 7 | command = "rustfmt" 8 | options = ["--edition", "2021"] 9 | includes = ["*.rs"] 10 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | - [ ] Labeled the PR with `patch`, `minor`, or `major` to request a version bump when it's merged. 2 | - [ ] Updated the user manual in `docs/`. 3 | - [ ] Added integration / regression tests in `tests/`. 4 | -------------------------------------------------------------------------------- /nix/packages/get-crate-version.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | writeShellApplication, 4 | ghciwatch, 5 | }: 6 | writeShellApplication { 7 | name = "get-crate-version"; 8 | 9 | text = '' 10 | echo ${lib.escapeShellArg ghciwatch.version} 11 | ''; 12 | } 13 | -------------------------------------------------------------------------------- /nix/makePackages.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | newScope, 4 | inputs, 5 | }: 6 | lib.makeScope newScope ( 7 | self: 8 | {inherit inputs;} 9 | // (lib.packagesFromDirectoryRecursive { 10 | inherit (self) callPackage; 11 | directory = ./packages; 12 | }) 13 | ) 14 | -------------------------------------------------------------------------------- /garnix.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # See: https://garnix.io/docs/yaml_config 3 | builds: 4 | include: 5 | - devShells.x86_64-linux.default 6 | - devShells.aarch64-darwin.default 7 | - 'packages.x86_64-linux.*' 8 | - 'packages.aarch64-darwin.*' 9 | - 'checks.x86_64-linux.*' 10 | - 'checks.aarch64-darwin.*' 11 | -------------------------------------------------------------------------------- /src/clap/mod.rs: -------------------------------------------------------------------------------- 1 | //! Adapters for parsing [`clap`] arguments to various types. 2 | 3 | mod camino; 4 | mod error_message; 5 | mod fmt_span; 6 | mod humantime; 7 | mod rust_backtrace; 8 | 9 | pub use self::humantime::DurationValueParser; 10 | pub use error_message::value_validation_error; 11 | pub use fmt_span::FmtSpanParserFactory; 12 | pub use rust_backtrace::RustBacktrace; 13 | -------------------------------------------------------------------------------- /docs/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = [ 3 | "Rebecca Turner", 4 | ] 5 | language = "en" 6 | multilingual = false 7 | src = "." 8 | title = "ghciwatch" 9 | 10 | [output.html] 11 | curly-quotes = true 12 | git-repository-url = "https://github.com/MercuryTechnologies/ghciwatch" 13 | git-repository-icon = "fa-github" 14 | edit-url-template = "https://github.com/MercuryTechnologies/ghciwatch/edit/main/docs/{path}" 15 | -------------------------------------------------------------------------------- /nix/packages/checks/treefmt.nix: -------------------------------------------------------------------------------- 1 | { 2 | mkCheck, 3 | treefmt, 4 | alejandra, 5 | craneLib, 6 | }: 7 | mkCheck { 8 | name = "treefmt"; 9 | nativeBuildInputs = [ 10 | treefmt 11 | alejandra 12 | craneLib.rustfmt 13 | ]; 14 | 15 | checkPhase = '' 16 | HOME="$PWD" treefmt --fail-on-change 17 | ''; 18 | 19 | meta.description = '' 20 | Check that treefmt runs without changes. 21 | ''; 22 | } 23 | -------------------------------------------------------------------------------- /src/format_bulleted_list.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use itertools::Itertools; 4 | 5 | /// Format an iterator of items into a bulleted list with line breaks between elements. 6 | pub fn format_bulleted_list(items: impl IntoIterator) -> String { 7 | let mut items = items.into_iter().peekable(); 8 | if items.peek().is_none() { 9 | String::new() 10 | } else { 11 | format!("• {}", items.join("\n• ")) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test-harness-macro/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "test-harness-macro" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | description = "Test attribute for ghciwatch" 7 | 8 | publish = false 9 | 10 | [lib] 11 | proc-macro = true 12 | 13 | [dependencies] 14 | quote = "1.0.33" 15 | syn = { version = "2.0.29", features = ["full"] } 16 | 17 | # See: https://github.com/crate-ci/cargo-release/blob/master/docs/reference.md 18 | [package.metadata.release] 19 | release = false 20 | -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # User Manual 2 | 3 | - [Introduction](./introduction.md) 4 | - [Installation](./install.md) 5 | - [Getting started](./getting-started.md) 6 | - [Command-line arguments](./cli.md) 7 | - [Lifecycle hooks](./lifecycle-hooks.md) 8 | - [Comment evaluation](./comment-evaluation.md) 9 | - [Only load modules you need](./no-load.md) 10 | - [FAQ](./faq.md) 11 | 12 | # Integration and tips 13 | 14 | - [Tasty](./integration/tasty.md) 15 | - [Multiple Cabal components](./integration/multiple-components.md) 16 | -------------------------------------------------------------------------------- /test-harness/src/matcher/never_matcher.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::Matcher; 4 | 5 | /// A matcher that never matches. 6 | #[derive(Clone)] 7 | pub struct NeverMatcher; 8 | 9 | impl Display for NeverMatcher { 10 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 11 | write!(f, "[nothing]") 12 | } 13 | } 14 | 15 | impl Matcher for NeverMatcher { 16 | fn matches(&mut self, _event: &crate::Event) -> miette::Result { 17 | Ok(false) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /nix/packages/mkCheck.nix: -------------------------------------------------------------------------------- 1 | { 2 | stdenv, 3 | inputs, 4 | }: { 5 | name, 6 | checkPhase, 7 | ... 8 | } @ args: let 9 | cleanedArgs = builtins.removeAttrs args ["name" "checkPhase"]; 10 | in 11 | stdenv.mkDerivation ({ 12 | name = "${name}-check"; 13 | 14 | src = inputs.self; 15 | 16 | phases = ["unpackPhase" "checkPhase" "installPhase"]; 17 | 18 | inherit checkPhase; 19 | doCheck = true; 20 | 21 | installPhase = '' 22 | touch $out 23 | ''; 24 | } 25 | // cleanedArgs) 26 | -------------------------------------------------------------------------------- /nix/packages/checksFrom.nix: -------------------------------------------------------------------------------- 1 | {lib}: let 2 | name = drv: drv.pname or drv.name; 3 | in 4 | drv: 5 | lib.mapAttrs' 6 | (_name: check: let 7 | drvName = name drv; 8 | checkName = name check; 9 | # If we have `ghciwatch.checks.ghciwatch-fmt` we want `ghciwatch-fmt`, 10 | # not `ghciwatch-ghciwatch-fmt`. 11 | newName = 12 | if lib.hasPrefix drvName checkName 13 | then checkName 14 | else "${drvName}-${checkName}"; 15 | in 16 | lib.nameValuePair newName check) 17 | drv.checks 18 | -------------------------------------------------------------------------------- /nix/packages/make-release-commit.nix: -------------------------------------------------------------------------------- 1 | { 2 | writeShellApplication, 3 | cargo, 4 | cargo-release, 5 | gitAndTools, 6 | }: 7 | writeShellApplication { 8 | name = "make-release-commit"; 9 | 10 | runtimeInputs = [ 11 | cargo 12 | cargo-release 13 | gitAndTools.git 14 | ]; 15 | 16 | text = '' 17 | if [[ -n "''${CI:-}" ]]; then 18 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 19 | git config --local user.name "github-actions[bot]" 20 | fi 21 | 22 | cargo release --version 23 | 24 | cargo release \ 25 | --execute \ 26 | --no-confirm \ 27 | "$@" 28 | ''; 29 | } 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: File a feature request. 3 | labels: ["feature", "triage"] 4 | body: 5 | - type: textarea 6 | id: feature 7 | attributes: 8 | label: Describe the feature you’d like to be implemented 9 | 10 | - type: textarea 11 | id: alternatives 12 | attributes: 13 | label: List alternatives to the feature and their pros and cons 14 | 15 | - type: textarea 16 | id: context 17 | attributes: 18 | label: Additional context 19 | description: >- 20 | Why do you want this feature implemented? 21 | Why don't existing features cover your use-case? 22 | -------------------------------------------------------------------------------- /nix/packages/checks/haskell-project.nix: -------------------------------------------------------------------------------- 1 | { 2 | mkCheck, 3 | ghciwatch, 4 | }: 5 | mkCheck { 6 | name = "haskell-project"; 7 | sourceRoot = "source/tests/data/simple"; 8 | nativeBuildInputs = ghciwatch.haskellInputs; 9 | inherit (ghciwatch) GHC_VERSIONS; 10 | 11 | checkPhase = '' 12 | # Need an empty `.cabal/config` or `cabal` errors trying to use the network. 13 | mkdir "$TMPDIR/.cabal" 14 | touch "$TMPDIR/.cabal/config" 15 | export HOME="$TMPDIR" 16 | 17 | for VERSION in $GHC_VERSIONS; do 18 | make test GHC="ghc-$VERSION" 19 | done 20 | ''; 21 | 22 | meta.description = '' 23 | Check that the Haskell project used for integration tests compiles. 24 | ''; 25 | } 26 | -------------------------------------------------------------------------------- /src/cwd.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use camino::Utf8PathBuf; 4 | use miette::miette; 5 | use miette::Context; 6 | use miette::IntoDiagnostic; 7 | 8 | /// Get the current working directory of the process with [`std::env::current_dir`]. 9 | pub fn current_dir() -> miette::Result { 10 | std::env::current_dir() 11 | .into_diagnostic() 12 | .wrap_err("Failed to get current directory") 13 | } 14 | 15 | /// Get the current working directory of the process as a [`Utf8PathBuf`]. 16 | pub fn current_dir_utf8() -> miette::Result { 17 | current_dir()? 18 | .try_into() 19 | .map_err(|path| miette!("Current directory isn't valid UTF-8: {path:?}")) 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/label-issues.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # This workflow runs when issues are opened and labels them `linear`. 3 | 4 | on: 5 | issues: 6 | types: 7 | - opened 8 | 9 | name: Label issues with `linear` 10 | 11 | jobs: 12 | label: 13 | name: Label issue with `linear` 14 | runs-on: ubuntu-latest 15 | permissions: 16 | issues: write 17 | steps: 18 | - uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 19 | with: 20 | script: | 21 | github.rest.issues.addLabels({ 22 | issue_number: context.issue.number, 23 | owner: context.repo.owner, 24 | repo: context.repo.repo, 25 | labels: ["linear"] 26 | }) 27 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | ## Getting started 2 | 3 | To start a ghciwatch session, you'll need a command to start a GHCi session 4 | (like `cabal repl`) and a set of paths and directories to watch for changes. 5 | For example: 6 | 7 | ghciwatch --command "cabal repl lib:test-dev" \ 8 | --watch src --watch test 9 | 10 | Check out the [examples](cli.md#examples) and [command-line 11 | arguments](cli.md#options) for more information. 12 | 13 | Ghciwatch can [run test suites](cli.md#--test-ghci) after reloads, [evaluate 14 | code in comments](cli.md#--enable-eval), [log compiler errors to a 15 | file](cli.md#--error-file), run [startup hooks](cli.md#--before-startup-shell) 16 | like [`hpack`][hpack] to generate `.cabal` files, and more! 17 | 18 | [hpack]: https://github.com/sol/hpack 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report. 3 | labels: ["bug", "triage"] 4 | body: 5 | - type: textarea 6 | id: what-happened 7 | attributes: 8 | label: What happened? 9 | 10 | - type: textarea 11 | id: expected 12 | attributes: 13 | label: What did you expect to happen? 14 | 15 | - type: textarea 16 | id: steps 17 | attributes: 18 | label: Steps to reproduce the issue 19 | description: >- 20 | Please be as specific as possible. A sample project is helpful here as 21 | bugs are often sensitive to GHC options. 22 | 23 | - type: input 24 | id: version 25 | attributes: 26 | label: The version of ghciwatch with the bug 27 | description: Output of `ghciwatch --version` 28 | -------------------------------------------------------------------------------- /src/command_ext.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command as StdCommand; 2 | 3 | use tokio::process::Command; 4 | 5 | /// Extension trait for commands. 6 | pub trait CommandExt { 7 | /// Display the command as a string, suitable for user output. 8 | /// 9 | /// Arguments and program names should be quoted with [`shell_words::quote`]. 10 | fn display(&self) -> String; 11 | } 12 | 13 | impl CommandExt for Command { 14 | fn display(&self) -> String { 15 | self.as_std().display() 16 | } 17 | } 18 | 19 | impl CommandExt for StdCommand { 20 | fn display(&self) -> String { 21 | let program = self.get_program().to_string_lossy(); 22 | 23 | let args = self.get_args().map(|arg| arg.to_string_lossy()); 24 | 25 | let tokens = std::iter::once(program).chain(args); 26 | 27 | shell_words::join(tokens) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/ghci/parse/mod.rs: -------------------------------------------------------------------------------- 1 | //! Parsers for `ghci` output and Haskell code. 2 | 3 | mod eval; 4 | mod ghc_message; 5 | mod haskell_grammar; 6 | mod lines; 7 | mod module_and_files; 8 | mod show_paths; 9 | mod show_targets; 10 | 11 | use haskell_grammar::module_name; 12 | use lines::rest_of_line; 13 | use module_and_files::module_and_files; 14 | 15 | pub use eval::parse_eval_commands; 16 | pub use eval::EvalCommand; 17 | pub use ghc_message::parse_ghc_messages; 18 | pub use ghc_message::CompilationResult; 19 | pub use ghc_message::CompilationSummary; 20 | pub use ghc_message::GhcDiagnostic; 21 | pub use ghc_message::GhcMessage; 22 | pub use ghc_message::ModulesLoaded; 23 | pub use ghc_message::Severity; 24 | pub use module_and_files::CompilingModule; 25 | pub use show_paths::parse_show_paths; 26 | pub use show_paths::ShowPaths; 27 | pub use show_targets::parse_show_targets; 28 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # Build `ghciwatch` 2 | build: 3 | cargo build 4 | 5 | # Run tests, including integration tests 6 | test *OPTIONS: 7 | cargo nextest run 8 | 9 | # Generate `docs/cli.md` 10 | _docs_cli_md: 11 | # It would be really nice if `mdbook` supported running commands before 12 | # rendering. 13 | cargo run --features clap-markdown -- --generate-markdown-help > docs/cli.md 14 | 15 | # Build the user manual to `docs/book` 16 | docs: _docs_cli_md 17 | mdbook build docs 18 | 19 | # Serve the user manual on `http://localhost:3000` 20 | serve-docs: _docs_cli_md 21 | mdbook serve docs 22 | 23 | # Generate API documentation with rustdoc (like CI) 24 | api-docs: 25 | cargo doc --document-private-items --no-deps --workspace 26 | 27 | # Lint Rust code with clippy 28 | lint: 29 | cargo clippy 30 | 31 | # Format Rust code 32 | format: 33 | cargo fmt 34 | -------------------------------------------------------------------------------- /tests/dot_ghci.rs: -------------------------------------------------------------------------------- 1 | use test_harness::test; 2 | use test_harness::Fs; 3 | use test_harness::GhciWatchBuilder; 4 | 5 | use indoc::indoc; 6 | 7 | /// Test that `ghciwatch` can run with a custom prompt in `.ghci`. 8 | #[test] 9 | async fn can_run_with_custom_ghci_prompt() { 10 | let mut session = GhciWatchBuilder::new("tests/data/simple") 11 | .before_start(|project| async move { 12 | Fs::new() 13 | .write( 14 | project.join(".ghci"), 15 | indoc!( 16 | r#" 17 | :set prompt "λ " 18 | :set prompt-cont "│ " 19 | "# 20 | ), 21 | ) 22 | .await?; 23 | Ok(()) 24 | }) 25 | .start() 26 | .await 27 | .expect("ghciwatch starts"); 28 | 29 | session.wait_until_ready().await.unwrap(); 30 | } 31 | -------------------------------------------------------------------------------- /test-harness/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "test-harness" 3 | version = "0.1.0" 4 | edition = "2021" 5 | description = "Test harness for ghciwatch" 6 | publish = false 7 | 8 | [dependencies] 9 | backoff = { version = "0.4.0", default-features = false } 10 | clonable-command = "0.1.0" 11 | fs_extra = "1.3.0" 12 | futures-util = "0.3.28" 13 | itertools = "0.11.0" 14 | miette = { version = "5.9.0", features = ["fancy"] } 15 | nix = { version = "0.26.2", default-features = false, features = ["process", "signal"] } 16 | regex = "1.9.4" 17 | serde = { version = "1.0.186", features = ["derive"] } 18 | serde_json = "1.0.105" 19 | shell-words = "1.1.0" 20 | tap = "1.0.1" 21 | tempfile = "3.8.0" 22 | test-harness-macro = { path = "../test-harness-macro" } 23 | test_bin = "0.4.0" 24 | tokio = { version = "1.28.2", features = ["full", "tracing"] } 25 | tracing = "0.1.37" 26 | 27 | # See: https://github.com/crate-ci/cargo-release/blob/master/docs/reference.md 28 | [package.metadata.release] 29 | release = false 30 | -------------------------------------------------------------------------------- /tests/data/simple/Makefile: -------------------------------------------------------------------------------- 1 | CABAL_OPTS ?= 2 | CABAL ?= cabal \ 3 | $(if $(GHC), --with-compiler=$(GHC)) \ 4 | $(CABAL_OPTS) 5 | 6 | EXTRA_GHC_OPTS ?= 7 | GHC_OPTS ?= \ 8 | -fwrite-interface \ 9 | $(EXTRA_GHC_OPTS) 10 | 11 | GHCI_OPTS ?= \ 12 | $(GHC_OPTS) \ 13 | -hisuf ghci_hi 14 | 15 | CABAL_REPL ?= $(CABAL) \ 16 | --repl-options='$(GHCI_OPTS)' \ 17 | v2-repl lib:test-dev 18 | 19 | GHCIWATCH_OPTS ?= 20 | GHCIWATCH ?= ../../../target/release/ghciwatch \ 21 | --command "$(CABAL_REPL)" \ 22 | --before-startup-shell "make my-simple-package.cabal" \ 23 | --watch src \ 24 | --watch test \ 25 | --watch test-main \ 26 | $(GHCIWATCH_OPTS) 27 | 28 | my-simple-package.cabal: package.yaml 29 | hpack . 30 | 31 | .PHONY: build 32 | build: my-simple-package.cabal 33 | $(CABAL) --ghc-options='$(GHC_OPTS)' build lib:test-dev 34 | 35 | .PHONY: test 36 | test: my-simple-package.cabal build 37 | $(CABAL) test 38 | echo ":quit" | $(CABAL_REPL) 39 | 40 | .PHONY: ghci 41 | ghci: my-simple-package.cabal 42 | $(CABAL_REPL) 43 | 44 | .PHONY: ghciwatch 45 | ghciwatch: 46 | $(GHCIWATCH) 47 | -------------------------------------------------------------------------------- /test-harness/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Test harness library for `ghciwatch` integration tests. 2 | #![deny(missing_docs)] 3 | #![deny(rustdoc::broken_intra_doc_links)] 4 | 5 | pub use serde_json::Value as JsonValue; 6 | 7 | mod tracing_json; 8 | pub use tracing_json::Event; 9 | pub use tracing_json::Span; 10 | 11 | mod tracing_reader; 12 | 13 | mod matcher; 14 | pub use matcher::BaseMatcher; 15 | pub use matcher::IntoMatcher; 16 | pub use matcher::Matcher; 17 | pub use matcher::NegativeMatcher; 18 | pub use matcher::NeverMatcher; 19 | pub use matcher::OptionMatcher; 20 | pub use matcher::OrMatcher; 21 | pub use matcher::SpanMatcher; 22 | 23 | mod fs; 24 | pub use fs::Fs; 25 | 26 | pub mod internal; 27 | 28 | /// Marks a function as an `async` test for use with a [`GhciWatch`] session. 29 | /// 30 | pub use test_harness_macro::test; 31 | 32 | mod ghciwatch; 33 | pub use ghciwatch::GhciWatch; 34 | pub use ghciwatch::GhciWatchBuilder; 35 | 36 | mod ghc_version; 37 | pub use ghc_version::FullGhcVersion; 38 | pub use ghc_version::GhcVersion; 39 | 40 | mod checkpoint; 41 | pub use checkpoint::Checkpoint; 42 | pub use checkpoint::CheckpointIndex; 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mercury 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/data/simple/package.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | spec-version: 0.36.0 3 | verbatim: 4 | cabal-version: 3.0 5 | name: my-simple-package 6 | version: 0.1.0.0 7 | author: Rebecca Turner 8 | 9 | ghc-options: -Wall 10 | 11 | dependencies: 12 | - base 13 | 14 | flags: 15 | local-dev: 16 | description: Turn on development settings, like auto-reload templates. 17 | manual: true 18 | default: false 19 | 20 | library: 21 | source-dirs: src 22 | 23 | tests: 24 | test: 25 | main: Main.hs 26 | source-dirs: 27 | - test-main 28 | ghc-options: -threaded -rtsopts -with-rtsopts=-N 29 | when: 30 | - condition: flag(local-dev) 31 | then: 32 | dependencies: 33 | - test-dev 34 | else: 35 | dependencies: 36 | - my-simple-package 37 | - test-lib 38 | 39 | internal-libraries: 40 | test-lib: 41 | source-dirs: 42 | - test 43 | 44 | test-dev: 45 | source-dirs: 46 | - test 47 | - src 48 | when: 49 | - condition: flag(local-dev) 50 | then: 51 | buildable: true 52 | else: 53 | buildable: false 54 | -------------------------------------------------------------------------------- /src/clap/error_message.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use owo_colors::OwoColorize; 4 | use owo_colors::Stream::Stdout; 5 | 6 | /// Construct a [`clap::Error`] formatted like the builtin error messages, which are constructed 7 | /// with a private API. (!) 8 | /// 9 | /// This is a sad little hack while the maintainer blocks my PRs: 10 | /// 11 | pub fn value_validation_error( 12 | arg: Option<&clap::Arg>, 13 | bad_value: &str, 14 | message: impl Display, 15 | ) -> clap::Error { 16 | clap::Error::raw( 17 | clap::error::ErrorKind::ValueValidation, 18 | format!( 19 | "invalid value '{bad_value}' for '{arg}': {message}\n\n\ 20 | For more information, try '{help}'.\n", 21 | bad_value = bad_value.if_supports_color(Stdout, |text| text.yellow()), 22 | arg = arg 23 | .map(ToString::to_string) 24 | .unwrap_or_else(|| "...".to_owned()) 25 | .if_supports_color(Stdout, |text| text.bold()), 26 | help = "--help".if_supports_color(Stdout, |text| text.bold()), 27 | ), 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/ghci/parse/ghc_message/single_quote.rs: -------------------------------------------------------------------------------- 1 | use winnow::token::one_of; 2 | use winnow::PResult; 3 | use winnow::Parser; 4 | 5 | /// Parse a single quote as GHC prints them. 6 | /// 7 | /// These may either be "GNU-style" quotes: 8 | /// 9 | /// ```text 10 | /// `foo' 11 | /// ``` 12 | /// 13 | /// Or Unicode single quotes: 14 | /// ```text 15 | /// ‘foo’ 16 | /// ``` 17 | pub fn single_quote(input: &mut &str) -> PResult { 18 | one_of(['`', '\'', '‘', '’']).parse_next(input) 19 | } 20 | 21 | #[cfg(test)] 22 | mod tests { 23 | use super::*; 24 | 25 | use pretty_assertions::assert_eq; 26 | 27 | #[test] 28 | fn test_parse_single_quote() { 29 | assert_eq!(single_quote.parse("\'").unwrap(), '\''); 30 | assert_eq!(single_quote.parse("`").unwrap(), '`'); 31 | assert_eq!(single_quote.parse("‘").unwrap(), '‘'); 32 | assert_eq!(single_quote.parse("’").unwrap(), '’'); 33 | 34 | assert!(single_quote.parse("''").is_err()); 35 | assert!(single_quote.parse(" '").is_err()); 36 | assert!(single_quote.parse("' ").is_err()); 37 | assert!(single_quote.parse("`foo'").is_err()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.config/nextest.toml: -------------------------------------------------------------------------------- 1 | # https://nexte.st/book/configuration.html 2 | 3 | [test-groups.serial-integration] 4 | # Run integration tests serially. 5 | # We only apply this setting in the `ci` profile; the CI builders are small 6 | # enough that running multiple integration tests at the same time actually 7 | # makes the entire test suite complete slower. 8 | max-threads = 1 9 | 10 | [profile.ci] 11 | # Print out output for failing tests as soon as they fail, and also at the end 12 | # of the run (for easy scrollability). 13 | failure-output = "immediate-final" 14 | # Do not cancel the test run on the first failure. 15 | fail-fast = false 16 | # The Garnix CI builders run in some weird virtual filesystem that messes with 17 | # `notify`. Even with sleeps before writing and poll-based notifications, 18 | # sometimes `notify` misses events (this is rare, maybe 1 in 50 test runs). 19 | # Retry tests if they fail in CI to mitigate this. 20 | retries = 3 21 | 22 | [[profile.ci.overrides]] 23 | # `kind(test)` means integration tests in the `../tests/` directory. 24 | # https://nexte.st/book/filter-expressions.html#basic-predicates 25 | filter = 'package(ghciwatch) and kind(test)' 26 | platform = 'cfg(linux)' 27 | test-group = 'serial-integration' 28 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | 4 | Packaging status 5 | 6 | 7 | ## Nixpkgs 8 | 9 | Ghciwatch is [available in `nixpkgs` as `ghciwatch`][nixpkgs]: 10 | 11 | ```shell 12 | nix-env -iA ghciwatch 13 | nix profile install nixpkgs#ghciwatch 14 | # Or add to your `/etc/nixos/configuration.nix`. 15 | ``` 16 | 17 | [nixpkgs]: https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/gh/ghciwatch/package.nix 18 | 19 | ## Statically-linked binaries 20 | 21 | Statically-linked binaries for aarch64/x86_64 macOS/Linux can be downloaded 22 | from the [GitHub releases][latest]. 23 | 24 | [latest]: https://github.com/MercuryTechnologies/ghciwatch/releases/latest 25 | 26 | ## Crates.io 27 | 28 | The Rust crate can be downloaded from [crates.io][crate]: 29 | 30 | ```shell 31 | cargo install ghciwatch 32 | ``` 33 | 34 | [crate]: https://crates.io/crates/ghciwatch 35 | 36 | ## Hackage 37 | 38 | Ghciwatch is not yet available on [Hackage][hackage]; see [issue #23][issue-23]. 39 | 40 | [hackage]: https://hackage.haskell.org/ 41 | [issue-23]: https://github.com/MercuryTechnologies/ghciwatch/issues/23 42 | -------------------------------------------------------------------------------- /tests/remove.rs: -------------------------------------------------------------------------------- 1 | use test_harness::test; 2 | use test_harness::BaseMatcher; 3 | use test_harness::GhciWatch; 4 | 5 | #[test] 6 | async fn can_remove_multiple_modules_at_once() { 7 | let mut session = GhciWatch::new("tests/data/simple") 8 | .await 9 | .expect("ghciwatch starts"); 10 | session 11 | .wait_until_ready() 12 | .await 13 | .expect("ghciwatch loads ghci"); 14 | 15 | session.fs_mut().disable_load_bearing_sleep(); 16 | session 17 | .fs() 18 | .remove(session.path("src/MyLib.hs")) 19 | .await 20 | .unwrap(); 21 | session 22 | .fs() 23 | .remove(session.path("src/MyModule.hs")) 24 | .await 25 | .unwrap(); 26 | session.fs_mut().reset_load_bearing_sleep(); 27 | 28 | session 29 | .wait_for_log(BaseMatcher::ghci_remove()) 30 | .await 31 | .expect("ghciwatch reloads on changes"); 32 | session 33 | .wait_for_log(BaseMatcher::compilation_succeeded()) 34 | .await 35 | .expect("ghciwatch reloads successfully"); 36 | session 37 | .wait_for_log(BaseMatcher::reload_completes()) 38 | .await 39 | .expect("ghciwatch finishes reloading"); 40 | } 41 | -------------------------------------------------------------------------------- /tests/load.rs: -------------------------------------------------------------------------------- 1 | use indoc::indoc; 2 | 3 | use test_harness::test; 4 | use test_harness::GhciWatch; 5 | 6 | /// Test that `ghciwatch` can start up `ghci` and load a session. 7 | #[test] 8 | async fn can_load() { 9 | let mut session = GhciWatch::new("tests/data/simple") 10 | .await 11 | .expect("ghciwatch starts"); 12 | session 13 | .wait_until_ready() 14 | .await 15 | .expect("ghciwatch loads ghci"); 16 | } 17 | 18 | /// Test that `ghciwatch` can load new modules. 19 | #[test] 20 | async fn can_load_new_module() { 21 | let mut session = GhciWatch::new("tests/data/simple") 22 | .await 23 | .expect("ghciwatch starts"); 24 | session 25 | .wait_until_ready() 26 | .await 27 | .expect("ghciwatch loads ghci"); 28 | session 29 | .fs() 30 | .write( 31 | session.path("src/My/Module.hs"), 32 | indoc!( 33 | "module My.Module (myIdent) where 34 | myIdent :: () 35 | myIdent = () 36 | " 37 | ), 38 | ) 39 | .await 40 | .unwrap(); 41 | session 42 | .wait_until_add() 43 | .await 44 | .expect("ghciwatch loads new modules"); 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/pages.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # This workflow runs when PRs are merged and tags/builds/publishes a release. 3 | 4 | # Run when PRs to main are closed. 5 | on: 6 | push: 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | name: Build and publish a release 12 | 13 | jobs: 14 | github-pages: 15 | name: Publish user manual to GitHub Pages 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: cachix/install-nix-action@7be5dee1421f63d07e71ce6e0a9f8a4b07c2a487 # v31.6.1 19 | with: 20 | github_access_token: ${{ secrets.GITHUB_TOKEN }} 21 | extra_nix_config: | 22 | extra-experimental-features = nix-command flakes 23 | accept-flake-config = true 24 | 25 | - name: Checkout code 26 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 27 | 28 | - name: Build user manual 29 | run: | 30 | RESULT=$(nix build --no-link --print-out-paths --print-build-logs .#ghciwatch.user-manual) 31 | cp -r "$RESULT/share/ghciwatch/html-manual" ghciwatch-user-manual 32 | 33 | - name: Publish to GitHub Pages 34 | uses: JamesIves/github-pages-deploy-action@6c2d9db40f9296374acc17b90404b6e8864128c8 # v4.7.3 35 | with: 36 | folder: ghciwatch-user-manual/ 37 | -------------------------------------------------------------------------------- /src/string_case.rs: -------------------------------------------------------------------------------- 1 | /// Extension for changing the case of strings. 2 | pub trait StringCase { 3 | /// Capitalize the first character of the string, if it's an ASCII codepoint. 4 | fn first_char_to_ascii_uppercase(&self) -> String; 5 | } 6 | 7 | impl StringCase for S 8 | where 9 | S: AsRef, 10 | { 11 | fn first_char_to_ascii_uppercase(&self) -> String { 12 | let s = self.as_ref(); 13 | let mut ret = String::with_capacity(s.len()); 14 | 15 | let mut chars = s.chars(); 16 | 17 | match chars.next() { 18 | Some(c) => { 19 | ret.push(c.to_ascii_uppercase()); 20 | } 21 | None => { 22 | return ret; 23 | } 24 | } 25 | 26 | ret.extend(chars); 27 | 28 | ret 29 | } 30 | } 31 | 32 | #[cfg(test)] 33 | mod tests { 34 | use super::*; 35 | 36 | #[test] 37 | fn test_to_sentence_case() { 38 | assert_eq!("dog".first_char_to_ascii_uppercase(), "Dog"); 39 | assert_eq!("puppy dog".first_char_to_ascii_uppercase(), "Puppy dog"); 40 | assert_eq!("puppy-dog".first_char_to_ascii_uppercase(), "Puppy-dog"); 41 | assert_eq!("Puppy-dog".first_char_to_ascii_uppercase(), "Puppy-dog"); 42 | assert_eq!("".first_char_to_ascii_uppercase(), ""); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/all_good.rs: -------------------------------------------------------------------------------- 1 | use test_harness::test; 2 | use test_harness::BaseMatcher; 3 | use test_harness::GhciWatchBuilder; 4 | use test_harness::Matcher; 5 | 6 | /// Test that `ghciwatch` can detect when compilation fails. 7 | /// 8 | /// Regression test for DUX-1649. 9 | #[test] 10 | async fn can_detect_compilation_failure() { 11 | let mut session = GhciWatchBuilder::new("tests/data/simple") 12 | .start() 13 | .await 14 | .expect("ghciwatch starts"); 15 | let module_path = session.path("src/MyModule.hs"); 16 | 17 | session.wait_until_ready().await.expect("ghciwatch loads"); 18 | 19 | session 20 | .fs() 21 | .replace(&module_path, "example :: String", "example :: ()") 22 | .await 23 | .unwrap(); 24 | 25 | session 26 | .wait_for_log(BaseMatcher::compilation_failed()) 27 | .await 28 | .unwrap(); 29 | 30 | session 31 | .wait_for_log(BaseMatcher::reload_completes().but_not(BaseMatcher::message("All good!"))) 32 | .await 33 | .unwrap(); 34 | 35 | session 36 | .fs() 37 | .replace(&module_path, "example :: ()", "example :: String") 38 | .await 39 | .unwrap(); 40 | 41 | session 42 | .wait_for_log(BaseMatcher::message("All good!")) 43 | .await 44 | .unwrap(); 45 | } 46 | -------------------------------------------------------------------------------- /tests/failed_modules.rs: -------------------------------------------------------------------------------- 1 | use test_harness::test; 2 | use test_harness::Fs; 3 | use test_harness::GhciWatchBuilder; 4 | 5 | /// Test that `ghciwatch` can start with compile errors. 6 | /// 7 | /// This is a regression test for [#43](https://github.com/MercuryTechnologies/ghciwatch/issues/43). 8 | #[test] 9 | async fn can_start_with_failed_modules() { 10 | let module_path = "src/MyModule.hs"; 11 | let mut session = GhciWatchBuilder::new("tests/data/simple") 12 | .before_start(move |path| async move { 13 | Fs::new() 14 | .replace(path.join(module_path), "example :: String", "example :: ()") 15 | .await 16 | }) 17 | .start() 18 | .await 19 | .expect("ghciwatch starts"); 20 | let module_path = session.path(module_path); 21 | 22 | session 23 | .wait_for_log("Compilation failed") 24 | .await 25 | .expect("ghciwatch fails to load with errors"); 26 | 27 | session.wait_until_ready().await.expect("ghciwatch loads"); 28 | 29 | session 30 | .fs() 31 | .replace(&module_path, "example :: ()", "example :: String") 32 | .await 33 | .unwrap(); 34 | 35 | session 36 | .wait_for_log("Compilation succeeded") 37 | .await 38 | .expect("ghciwatch reloads fixed modules"); 39 | } 40 | -------------------------------------------------------------------------------- /src/clap/camino.rs: -------------------------------------------------------------------------------- 1 | //! Adapter for parsing [`camino::Utf8PathBuf`] with a [`clap::builder::Arg::value_parser`]. 2 | 3 | use camino::Utf8PathBuf; 4 | use clap::builder::PathBufValueParser; 5 | use clap::builder::TypedValueParser; 6 | use clap::builder::ValueParserFactory; 7 | 8 | #[derive(Default, Clone)] 9 | struct Utf8PathBufValueParser { 10 | inner: PathBufValueParser, 11 | } 12 | 13 | impl TypedValueParser for Utf8PathBufValueParser { 14 | type Value = Utf8PathBuf; 15 | 16 | fn parse_ref( 17 | &self, 18 | cmd: &clap::Command, 19 | arg: Option<&clap::Arg>, 20 | value: &std::ffi::OsStr, 21 | ) -> Result { 22 | self.inner.parse_ref(cmd, arg, value).and_then(|path_buf| { 23 | Utf8PathBuf::from_path_buf(path_buf).map_err(|path_buf| { 24 | clap::Error::raw( 25 | clap::error::ErrorKind::InvalidUtf8, 26 | format!("Path isn't UTF-8: {path_buf:?}"), 27 | ) 28 | .with_cmd(cmd) 29 | }) 30 | }) 31 | } 32 | } 33 | 34 | struct Utf8PathBufValueParserFactory; 35 | 36 | impl ValueParserFactory for Utf8PathBufValueParserFactory { 37 | type Parser = Utf8PathBufValueParser; 38 | 39 | fn value_parser() -> Self::Parser { 40 | Self::Parser::default() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/aho_corasick.rs: -------------------------------------------------------------------------------- 1 | //! Extensions and utilities for the [`aho_corasick`] crate. 2 | 3 | use aho_corasick::AhoCorasick; 4 | use aho_corasick::Anchored; 5 | use aho_corasick::Input; 6 | use aho_corasick::Match; 7 | use aho_corasick::StartKind; 8 | 9 | /// Extension trait for [`AhoCorasick`]. 10 | pub trait AhoCorasickExt { 11 | /// Attempt to match at the start of the input. 12 | fn find_at_start(&self, input: &str) -> Option; 13 | 14 | /// Attempt to match anywhere in the input. 15 | fn find_anywhere(&self, input: &str) -> Option; 16 | 17 | /// Build a matcher from the given set of patterns, with anchored matching enabled (matching at 18 | /// the start of the string only). 19 | fn from_anchored_patterns(patterns: impl IntoIterator>) -> Self; 20 | } 21 | 22 | impl AhoCorasickExt for AhoCorasick { 23 | fn find_at_start(&self, input: &str) -> Option { 24 | self.find(Input::new(input).anchored(Anchored::Yes)) 25 | } 26 | 27 | fn find_anywhere(&self, input: &str) -> Option { 28 | self.find(Input::new(input).anchored(Anchored::No)) 29 | } 30 | 31 | fn from_anchored_patterns(patterns: impl IntoIterator>) -> Self { 32 | Self::builder() 33 | .start_kind(StartKind::Both) 34 | .build(patterns) 35 | .unwrap() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test-harness/src/matcher/into_matcher.rs: -------------------------------------------------------------------------------- 1 | use miette::Diagnostic; 2 | 3 | use crate::BaseMatcher; 4 | use crate::Matcher; 5 | 6 | /// A type that can be converted into a [`Matcher`] and used for searching log events. 7 | pub trait IntoMatcher { 8 | /// The resulting [`Matcher`] type. 9 | type Matcher: Matcher; 10 | 11 | /// Convert the object into a `Matcher`. 12 | fn into_matcher(self) -> miette::Result; 13 | } 14 | 15 | impl IntoMatcher for M 16 | where 17 | M: Matcher, 18 | { 19 | type Matcher = Self; 20 | 21 | fn into_matcher(self) -> miette::Result { 22 | Ok(self) 23 | } 24 | } 25 | 26 | impl IntoMatcher for &BaseMatcher { 27 | type Matcher = BaseMatcher; 28 | 29 | fn into_matcher(self) -> miette::Result { 30 | Ok(self.clone()) 31 | } 32 | } 33 | 34 | impl IntoMatcher for &str { 35 | type Matcher = BaseMatcher; 36 | 37 | fn into_matcher(self) -> miette::Result { 38 | Ok(BaseMatcher::message(self)) 39 | } 40 | } 41 | 42 | impl IntoMatcher for Result 43 | where 44 | M: IntoMatcher, 45 | E: Diagnostic + Send + Sync + 'static, 46 | { 47 | type Matcher = ::Matcher; 48 | 49 | fn into_matcher(self) -> miette::Result { 50 | self.map_err(|err| miette::Report::new(err))?.into_matcher() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/buffers.rs: -------------------------------------------------------------------------------- 1 | //! Constants for buffer sizes. 2 | //! 3 | //! This is kind of awkward, but marginally better than writing `1024` everywhere? 4 | //! Time will tell if we need more granular tuning than this. 5 | 6 | /// The default capacity (in bytes) of buffers storing a line of text. 7 | /// 8 | /// This should be large enough to accomodate most lines of output without resizing the buffer. 9 | /// We also don't need to allocate that many buffers at once, so it's fine for this to be 10 | /// substantially larger than the defaults. (IIRC the default sizes of `Vec`s and `String`s allow 11 | /// something like a dozen entries or so.) 12 | pub const LINE_BUFFER_CAPACITY: usize = 1024; 13 | 14 | /// The default capacity (in entries) of buffers storing a collection of items, usually lines. 15 | pub const VEC_BUFFER_CAPACITY: usize = 1024; 16 | 17 | /// If we need to split a codepiont in half, we know it won't have more than 4 bytes total. 18 | pub const SPLIT_UTF8_CODEPOINT_CAPACITY: usize = 4; 19 | 20 | /// Size of a buffer for tracing output. Used to implement the TUI. 21 | pub const TRACING_BUFFER_CAPACITY: usize = 1024; 22 | 23 | /// Size of a buffer for `ghci` output. Used to implement the TUI. 24 | pub const GHCI_BUFFER_CAPACITY: usize = 1024; 25 | 26 | /// Initial capacity for the TUI scrollback buffer, containing data written from `ghci` and 27 | /// `tracing` log messages. 28 | pub const TUI_SCROLLBACK_CAPACITY: usize = 16 * 1024; 29 | -------------------------------------------------------------------------------- /tests/test.rs: -------------------------------------------------------------------------------- 1 | use expect_test::expect; 2 | use test_harness::test; 3 | use test_harness::BaseMatcher; 4 | use test_harness::GhciWatchBuilder; 5 | 6 | /// Test that `ghciwatch --test ...` can run a test suite. 7 | #[test] 8 | async fn can_run_test_suite_on_reload() { 9 | let error_path = "ghcid.txt"; 10 | let mut session = GhciWatchBuilder::new("tests/data/simple") 11 | .with_args(["--test-ghci", "TestMain.testMain", "--errors", error_path]) 12 | .start() 13 | .await 14 | .expect("ghciwatch starts"); 15 | let error_path = session.path(error_path); 16 | session 17 | .wait_until_ready() 18 | .await 19 | .expect("ghciwatch loads ghci"); 20 | 21 | session 22 | .fs() 23 | .touch(session.path("src/MyLib.hs")) 24 | .await 25 | .expect("Can touch file"); 26 | 27 | session 28 | .wait_for_log(BaseMatcher::span_close().in_leaf_spans(["error_log_write"])) 29 | .await 30 | .expect("ghciwatch writes ghcid.txt"); 31 | session 32 | .wait_for_log("Finished running tests") 33 | .await 34 | .expect("ghciwatch runs the test suite"); 35 | 36 | let error_contents = session 37 | .fs() 38 | .read(&error_path) 39 | .await 40 | .expect("ghciwatch writes ghcid.txt"); 41 | expect![[r#" 42 | All good (3 modules) 43 | "#]] 44 | .assert_eq(&error_contents); 45 | } 46 | -------------------------------------------------------------------------------- /docs/integration/tasty.md: -------------------------------------------------------------------------------- 1 | # Tasty 2 | 3 | Tips and tricks for using ghciwatch with the [Tasty][tasty] test framework. 4 | 5 | [tasty]: https://hackage.haskell.org/package/tasty 6 | 7 | Ghciwatch will wait for GHCi to print output, and it can end up waiting forever 8 | if the Tasty output is buffered. Something like this works: 9 | 10 | ```haskell 11 | module TestMain where 12 | 13 | import Control.Exception (bracket) 14 | import System.IO (hGetBuffering, hSetBuffering, stdout) 15 | import Test.Tasty (TestTree, defaultMain, testGroup) 16 | 17 | -- | Run an `IO` action, restoring `stdout`\'s buffering mode after the action 18 | -- completes or errors. 19 | protectStdoutBuffering :: IO a -> IO a 20 | protectStdoutBuffering action = 21 | bracket 22 | (hGetBuffering stdout) 23 | (\bufferMode -> hSetBuffering stdout bufferMode) 24 | (const action) 25 | 26 | main :: IO () 27 | main = protectStdoutBuffering $ defaultMain $ mytestgroup 28 | ``` 29 | 30 | ## `tasty-discover` issues 31 | 32 | If you add a new test file, you may need to write the top level 33 | [`tasty-discover`][tasty-discover] module to convince ghciwatch to reload it. 34 | [`tasty-autocollect`][tasty-autocollect] relies on a compiler plugin and seems 35 | to avoid this problem. 36 | 37 | [tasty-discover]: https://hackage.haskell.org/package/tasty 38 | [tasty-autocollect]: https://github.com/MercuryTechnologies/ghciwatch/pull/321/files?short_path=c86caa3#diff-c86caa33ad4639b624ef8db59e739295f362bf4c211bed24c8ba484c79af9bdb 39 | -------------------------------------------------------------------------------- /src/ghci/parse/ghc_message/loaded_configuration.rs: -------------------------------------------------------------------------------- 1 | use camino::Utf8PathBuf; 2 | use winnow::PResult; 3 | use winnow::Parser; 4 | 5 | use crate::ghci::parse::lines::until_newline; 6 | 7 | use super::GhcMessage; 8 | 9 | /// Parse a `Loaded GHCi configuraton from /home/wiggles/.ghci` message. 10 | pub fn loaded_configuration(input: &mut &str) -> PResult { 11 | let _ = "Loaded GHCi configuration from ".parse_next(input)?; 12 | let path = until_newline.parse_next(input)?; 13 | 14 | Ok(GhcMessage::LoadConfig { 15 | path: Utf8PathBuf::from(path), 16 | }) 17 | } 18 | 19 | #[cfg(test)] 20 | mod tests { 21 | use super::*; 22 | 23 | use indoc::indoc; 24 | use pretty_assertions::assert_eq; 25 | 26 | #[test] 27 | fn test_parse_loaded_ghci_configuration_message() { 28 | assert_eq!( 29 | loaded_configuration 30 | .parse("Loaded GHCi configuration from /home/wiggles/.ghci\n") 31 | .unwrap(), 32 | GhcMessage::LoadConfig { 33 | path: "/home/wiggles/.ghci".into() 34 | } 35 | ); 36 | 37 | // It shouldn't parse another line. 38 | assert!(loaded_configuration 39 | .parse(indoc!( 40 | " 41 | Loaded GHCi configuration from /home/wiggles/.ghci 42 | [1 of 4] Compiling MyLib ( src/MyLib.hs, interpreted ) 43 | " 44 | )) 45 | .is_err()); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test-harness/src/matcher/span_matcher.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::tracing_json::Span; 4 | 5 | use super::FieldMatcher; 6 | 7 | /// A [`Span`] matcher. 8 | #[derive(Clone)] 9 | pub struct SpanMatcher { 10 | name: String, 11 | fields: FieldMatcher, 12 | } 13 | 14 | impl SpanMatcher { 15 | /// Construct a query for spans with the given name. 16 | pub fn new(name: impl AsRef) -> Self { 17 | Self { 18 | name: name.as_ref().to_owned(), 19 | fields: Default::default(), 20 | } 21 | } 22 | 23 | /// Require that matching spans contain a field with the given name and a value matching the 24 | /// given regex. 25 | /// 26 | /// ### Panics 27 | /// 28 | /// If the `value_regex` fails to compile. 29 | pub fn with_field(mut self, name: &str, value_regex: &str) -> Self { 30 | self.fields = self.fields.with_field(name, value_regex); 31 | self 32 | } 33 | 34 | /// Determine if this matcher matches the given [`Span`]. 35 | pub fn matches(&self, span: &Span) -> bool { 36 | span.name == self.name && self.fields.matches(|name| span.fields.get(name)) 37 | } 38 | } 39 | 40 | impl From<&str> for SpanMatcher { 41 | fn from(value: &str) -> Self { 42 | Self::new(value) 43 | } 44 | } 45 | 46 | impl Display for SpanMatcher { 47 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 48 | write!(f, "{:?}", self.name)?; 49 | 50 | if !self.fields.is_empty() { 51 | write!(f, " {}", self.fields)?; 52 | } 53 | 54 | Ok(()) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## Why a reimplementation? 4 | 5 | Ghciwatch started out as a reimplementation and reimagination of 6 | [`ghcid`][ghcid], a similar tool with smaller scope. When we started working on 7 | ghciwatch, `ghcid` suffered from some significant limitations. In particular, 8 | `ghcid` couldn't deal with moved or deleted modules, and wouldn't detect new 9 | directories because it [can't easily update the set of files being watched at 10 | runtime.][ghcid-wait] We've also seen memory leaks requiring multiple restarts 11 | per day. Due to the `ghcid` codebase's relatively small size, a 12 | reimplementation seemed like a more efficient path forward than making 13 | wide-spanning changes to an unfamiliar codebase. 14 | 15 | [ghcid]: https://github.com/ndmitchell/ghcid 16 | [ghcid-wait]: https://github.com/ndmitchell/ghcid/blob/e2852979aa644c8fed92d46ab529d2c6c1c62b59/src/Wait.hs#L81-L83 17 | 18 | ## Why not just use `watchexec` or similar? 19 | 20 | TL;DR: Managing a GHCi session is often faster than recompiling a project with 21 | `cabal` or similar. 22 | 23 | Recompiling a project when files change is a fairly common development task, so 24 | there's a bunch of tools with this same rough goal. In particular, 25 | [`watchexec`][watchexec] is a nice off-the-shelf solution. Why not just run 26 | `watchexec -e hs cabal build`? In truth, ghciwatch doesn't just recompile the 27 | project when it detects changes. It instead manages an interactive GHCi 28 | session, instructing it to reload modules when relevant. This involves a fairly 29 | complex dance of communicating to GHCi over stdin and parsing its stdout, so 30 | a bespoke tool is useful here. 31 | -------------------------------------------------------------------------------- /tests/clear.rs: -------------------------------------------------------------------------------- 1 | use indoc::indoc; 2 | 3 | use test_harness::test; 4 | use test_harness::BaseMatcher; 5 | use test_harness::GhciWatchBuilder; 6 | 7 | /// Test that `ghciwatch` clears the screen on reloads and restarts when `--clear` is used. 8 | #[test] 9 | async fn clears_on_reload_and_restart() { 10 | let mut session = GhciWatchBuilder::new("tests/data/simple") 11 | .with_arg("--clear") 12 | .with_log_filter("ghciwatch::ghci[clear]=trace") 13 | .start() 14 | .await 15 | .expect("ghciwatch starts"); 16 | session 17 | .wait_until_ready() 18 | .await 19 | .expect("ghciwatch loads ghci"); 20 | 21 | session 22 | .fs() 23 | .append( 24 | session.path("src/MyLib.hs"), 25 | indoc!( 26 | " 27 | 28 | hello = 1 :: Integer 29 | 30 | " 31 | ), 32 | ) 33 | .await 34 | .unwrap(); 35 | 36 | session.wait_for_log("Clearing the screen").await.unwrap(); 37 | session 38 | .wait_until_reload() 39 | .await 40 | .expect("ghciwatch reloads on changes"); 41 | session 42 | .wait_for_log(BaseMatcher::reload_completes()) 43 | .await 44 | .unwrap(); 45 | 46 | // Modify the `package.yaml` to trigger a restart. 47 | session 48 | .fs() 49 | .append(session.path("package.yaml"), "\n") 50 | .await 51 | .unwrap(); 52 | 53 | session.wait_for_log("Clearing the screen").await.unwrap(); 54 | session 55 | .wait_until_restart() 56 | .await 57 | .expect("ghciwatch restarts ghci"); 58 | } 59 | -------------------------------------------------------------------------------- /tests/rename.rs: -------------------------------------------------------------------------------- 1 | use test_harness::test; 2 | use test_harness::BaseMatcher; 3 | use test_harness::GhciWatch; 4 | use test_harness::Matcher; 5 | 6 | /// Test that `ghciwatch` can restart correctly when modules are removed and added (i.e., renamed) 7 | /// at the same time. 8 | #[test] 9 | async fn can_compile_renamed_module() { 10 | let mut session = GhciWatch::new("tests/data/simple") 11 | .await 12 | .expect("ghciwatch starts"); 13 | session 14 | .wait_until_ready() 15 | .await 16 | .expect("ghciwatch loads ghci"); 17 | 18 | let module_path = session.path("src/MyModule.hs"); 19 | let new_module_path = session.path("src/MyCoolModule.hs"); 20 | session 21 | .fs() 22 | .rename(&module_path, &new_module_path) 23 | .await 24 | .unwrap(); 25 | 26 | session 27 | .wait_for_log(BaseMatcher::ghci_add().and(BaseMatcher::ghci_remove())) 28 | .await 29 | .expect("ghciwatch adds and removes modules on module move"); 30 | 31 | // Weirdly GHCi is fine with modules that don't match the file name as long as you specify the 32 | // module by path and not by name. 33 | session 34 | .wait_for_log(BaseMatcher::compilation_succeeded()) 35 | .await 36 | .unwrap(); 37 | 38 | session 39 | .fs() 40 | .replace(new_module_path, "module MyModule", "module MyCoolModule") 41 | .await 42 | .unwrap(); 43 | 44 | session 45 | .wait_until_reload() 46 | .await 47 | .expect("ghciwatch reloads on module change"); 48 | 49 | session 50 | .wait_for_log(BaseMatcher::compilation_succeeded()) 51 | .await 52 | .unwrap(); 53 | } 54 | -------------------------------------------------------------------------------- /src/ghci/parse/ghc_message/path_colon.rs: -------------------------------------------------------------------------------- 1 | use camino::Utf8Path; 2 | use winnow::combinator::terminated; 3 | use winnow::token::take_till; 4 | use winnow::PResult; 5 | use winnow::Parser; 6 | 7 | /// A filename, followed by a `:`. 8 | pub fn path_colon<'i>(input: &mut &'i str) -> PResult<&'i Utf8Path> { 9 | // TODO: Support Windows drive letters. 10 | terminated(take_till(1.., (':', '\n')), ":") 11 | .parse_next(input) 12 | .map(Utf8Path::new) 13 | } 14 | 15 | #[cfg(test)] 16 | mod tests { 17 | use super::*; 18 | 19 | use pretty_assertions::assert_eq; 20 | 21 | #[test] 22 | fn test_parse_path_colon() { 23 | assert_eq!( 24 | path_colon.parse("./Foo.hs:").unwrap(), 25 | Utf8Path::new("./Foo.hs") 26 | ); 27 | assert_eq!( 28 | path_colon.parse("../Foo.hs:").unwrap(), 29 | Utf8Path::new("../Foo.hs") 30 | ); 31 | assert_eq!( 32 | path_colon.parse("foo/../Bar/./../../Foo/Foo.hs:").unwrap(), 33 | Utf8Path::new("foo/../Bar/./../../Foo/Foo.hs") 34 | ); 35 | assert_eq!( 36 | path_colon.parse("Foo/Bar.hs:").unwrap(), 37 | Utf8Path::new("Foo/Bar.hs") 38 | ); 39 | assert_eq!( 40 | path_colon.parse("/home/wiggles/Foo/Bar.hs:").unwrap(), 41 | Utf8Path::new("/home/wiggles/Foo/Bar.hs") 42 | ); 43 | 44 | // Whitespace 45 | assert!(path_colon.parse("/home/wiggles/Foo/Bar.hs: ").is_err()); 46 | // Newline in the middle! 47 | assert!(path_colon.parse("/home/wiggles\n/Foo/Bar.hs:").is_err()); 48 | // Missing colon. 49 | assert!(path_colon.parse("/home/wiggles/Foo/Bar.hs").is_err()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/haskell_source_file.rs: -------------------------------------------------------------------------------- 1 | //! Haskell source file and path tools. 2 | 3 | use camino::Utf8Path; 4 | 5 | /// File extensions for Haskell source code. 6 | pub const HASKELL_SOURCE_EXTENSIONS: [&str; 9] = [ 7 | // NOTE: This should start with `hs` so that iterators try the most common extension first. 8 | "hs", // Haskell 9 | "lhs", // Literate Haskell 10 | "hs-boot", // See: https://downloads.haskell.org/ghc/latest/docs/users_guide/separate_compilation.html#how-to-compile-mutually-recursive-modules 11 | "hsig", // Backpack module signatures: https://ghc.gitlab.haskell.org/ghc/doc/users_guide/separate_compilation.html#module-signatures 12 | "hsc", // `hsc2hs` C bindings: https://downloads.haskell.org/ghc/latest/docs/users_guide/utils.html?highlight=interfaces#writing-haskell-interfaces-to-c-code-hsc2hs 13 | "x", // `alex` (lexer generator): https://hackage.haskell.org/package/alex 14 | "y", // `happy` (parser generator): https://hackage.haskell.org/package/happy 15 | "c2hs", // `c2hs` C bindings: https://hackage.haskell.org/package/c2hs 16 | "gc", // `greencard` C bindings: https://hackage.haskell.org/package/greencard 17 | ]; 18 | 19 | /// Determine if a given path represents a Haskell source file. 20 | pub fn is_haskell_source_file(path: impl AsRef) -> bool { 21 | let path = path.as_ref(); 22 | // Haskell source files end in a known extension. 23 | path.extension() 24 | .map(|ext| HASKELL_SOURCE_EXTENSIONS.contains(&ext)) 25 | .unwrap_or(false) 26 | // Haskell source files do not start with `.` (Emacs swap files in particular start with 27 | // `.#`). 28 | && path 29 | .file_name() 30 | .is_some_and(|name| !name.starts_with('.')) 31 | } 32 | -------------------------------------------------------------------------------- /nix/packages/cargo-llvm-cov.nix: -------------------------------------------------------------------------------- 1 | # This package is marked broken upstream due to test failures and would be 2 | # built with a different version of `cargo`/`rust`, so we re-build it here. 3 | # 4 | # https://github.com/NixOS/nixpkgs/blob/16b7680853d2d0c7a120c21266eff4a2660a3207/pkgs/development/tools/rust/cargo-llvm-cov/default.nix 5 | { 6 | fetchurl, 7 | fetchFromGitHub, 8 | craneLib, 9 | git, 10 | }: let 11 | pname = "cargo-llvm-cov"; 12 | version = "0.6.9"; 13 | owner = "taiki-e"; 14 | 15 | src = fetchFromGitHub { 16 | inherit owner; 17 | repo = pname; 18 | rev = "v${version}"; 19 | sha256 = "sha256-fZrYmsulKOvgW/WtsYL7r4Cby+m9ShgXozxj1ZQ5ZAY="; 20 | }; 21 | 22 | # The upstream repo doesn't include a `Cargo.lock`. 23 | cargoLock = fetchurl { 24 | name = "Cargo.lock"; 25 | url = "https://crates.io/api/v1/crates/${pname}/${version}/download"; 26 | sha256 = "sha256-r4C7z2/z4OVEf+IhFe061E7FzSx0VzADmg56Lb+DO/g="; 27 | downloadToTemp = true; 28 | postFetch = '' 29 | tar xzf $downloadedFile ${pname}-${version}/Cargo.lock 30 | mv ${pname}-${version}/Cargo.lock $out 31 | ''; 32 | }; 33 | 34 | commonArgs' = { 35 | inherit pname version src; 36 | 37 | postUnpack = '' 38 | cp ${cargoLock} source/Cargo.lock 39 | ''; 40 | 41 | cargoVendorDir = craneLib.vendorCargoDeps { 42 | inherit src cargoLock; 43 | }; 44 | }; 45 | 46 | cargoArtifacts = craneLib.buildDepsOnly commonArgs'; 47 | 48 | commonArgs = 49 | commonArgs' 50 | // { 51 | inherit cargoArtifacts; 52 | 53 | nativeCheckInputs = [ 54 | git 55 | ]; 56 | 57 | # `cargo-llvm-cov` tests rely on `git ls-files`. 58 | preCheck = '' 59 | git init -b main 60 | git add . 61 | ''; 62 | }; 63 | in 64 | craneLib.buildPackage commonArgs 65 | -------------------------------------------------------------------------------- /src/clap/rust_backtrace.rs: -------------------------------------------------------------------------------- 1 | //! Adapter for parsing the `$RUST_BACKTRACE` environment variable with a 2 | //! [`clap::builder::Arg::value_parser`]. 3 | 4 | use std::fmt::Display; 5 | 6 | use clap::builder::EnumValueParser; 7 | use clap::builder::PossibleValue; 8 | use clap::builder::ValueParserFactory; 9 | 10 | /// Whether to display backtraces in errors. 11 | #[derive(Debug, Clone, Copy)] 12 | pub enum RustBacktrace { 13 | /// Hide backtraces in errors. 14 | Off, 15 | /// Display backtraces in errors. 16 | On, 17 | /// Display full backtraces in errors, including less-useful stack frames. 18 | Full, 19 | } 20 | 21 | impl clap::ValueEnum for RustBacktrace { 22 | fn value_variants<'a>() -> &'a [Self] { 23 | &[Self::Off, Self::On, Self::Full] 24 | } 25 | 26 | fn to_possible_value(&self) -> Option { 27 | Some(match self { 28 | RustBacktrace::Off => PossibleValue::new("0").help("Hide backtraces in errors"), 29 | RustBacktrace::On => PossibleValue::new("1").help("Display backtraces in errors"), 30 | RustBacktrace::Full => PossibleValue::new("full") 31 | .help("Display backtraces with all stack frames in errors"), 32 | }) 33 | } 34 | } 35 | 36 | impl Display for RustBacktrace { 37 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 38 | match self { 39 | RustBacktrace::Off => write!(f, "0"), 40 | RustBacktrace::On => write!(f, "1"), 41 | RustBacktrace::Full => write!(f, "full"), 42 | } 43 | } 44 | } 45 | 46 | struct RustBacktraceParserFactory; 47 | 48 | impl ValueParserFactory for RustBacktraceParserFactory { 49 | type Parser = EnumValueParser; 50 | 51 | fn value_parser() -> Self::Parser { 52 | Self::Parser::new() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! `ghciwatch` is a `ghci`-based file watcher and recompiler for Haskell projects, leveraging 2 | //! Haskell's interpreted mode for faster reloads. 3 | //! 4 | //! `ghciwatch` watches your modules for changes and reloads them in a `ghci` session, displaying 5 | //! any errors. 6 | //! 7 | //! Note that the `ghciwatch` Rust library is a convenience and shouldn't be depended on. I do not 8 | //! consider this to be a public/stable API and will make breaking changes here in minor version 9 | //! bumps. If you'd like a stable `ghciwatch` Rust API for some reason, let me know and we can maybe 10 | //! work something out. 11 | 12 | #![deny(missing_docs)] 13 | #![deny(rustdoc::broken_intra_doc_links)] 14 | // This is a false lint triggered due to expect_test generated code. 15 | #![allow(clippy::needless_raw_string_hashes)] 16 | 17 | mod aho_corasick; 18 | mod buffers; 19 | mod clap; 20 | pub mod clap_markdown; 21 | pub mod cli; 22 | mod clonable_command; 23 | mod command_ext; 24 | mod cwd; 25 | mod event_filter; 26 | mod format_bulleted_list; 27 | mod ghci; 28 | mod haskell_source_file; 29 | mod hooks; 30 | mod ignore; 31 | mod incremental_reader; 32 | mod maybe_async_command; 33 | mod normal_path; 34 | mod shutdown; 35 | mod string_case; 36 | mod tracing; 37 | mod tui; 38 | mod watcher; 39 | 40 | pub(crate) use cwd::current_dir; 41 | pub(crate) use cwd::current_dir_utf8; 42 | pub(crate) use format_bulleted_list::format_bulleted_list; 43 | pub(crate) use string_case::StringCase; 44 | 45 | pub use ghci::manager::run_ghci; 46 | pub use ghci::Ghci; 47 | pub use ghci::GhciOpts; 48 | pub use ghci::GhciWriter; 49 | pub use shutdown::ShutdownError; 50 | pub use shutdown::ShutdownHandle; 51 | pub use shutdown::ShutdownManager; 52 | pub use tracing::TracingOpts; 53 | pub use tui::run_tui; 54 | pub use watcher::run_watcher; 55 | pub use watcher::WatcherOpts; 56 | 57 | #[cfg(test)] 58 | mod fake_reader; 59 | 60 | pub(crate) use command_ext::CommandExt; 61 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # ghciwatch 2 | 3 | Ghciwatch loads a [GHCi][ghci] session for a Haskell project and reloads it 4 | when source files change. 5 | 6 | [ghci]: https://downloads.haskell.org/ghc/latest/docs/users_guide/ghci.html 7 | 8 | ## Features 9 | 10 | - GHCi output is displayed to the user as soon as it's printed. 11 | - Ghciwatch can handle new modules, removed modules, or moved modules without a 12 | hitch 13 | - A variety of [lifecycle hooks](lifecycle-hooks.md) let you run Haskell code 14 | or shell commands on a variety of events. 15 | - Run a test suite with [`--test-ghci 16 | TestMain.testMain`](cli.md#--test-ghci). 17 | - Refresh your `.cabal` files with [`hpack`][hpack] before GHCi starts using 18 | [`--before-startup-shell hpack`](cli.md#--before-startup-shell). 19 | - Format your code asynchronously using [`--before-reload-shell 20 | async:fourmolu`](cli.md#--before-reload-shell). 21 | - [Custom globs](cli.md#--reload-glob) can be supplied to reload or restart the 22 | GHCi session when non-Haskell files (like templates or database schema 23 | definitions) change. 24 | - Ghciwatch can [clear the screen between reloads](cli.md#--clear). 25 | - Compilation errors can be written to a file with [`--error-file`](cli.md#--error-file), for 26 | compatibility with [ghcid's][ghcid] `--outputfile` option. 27 | - Comments starting with `-- $>` [can be evaluated](comment-evaluation.md) in 28 | GHCi. 29 | - Eval comments have access to the top-level bindings of the module they're 30 | defined in, including unexported bindings. 31 | - Multi-line eval comments are supported with `{- $> ... <$ -}`. 32 | 33 | [ghcid]: https://github.com/ndmitchell/ghcid 34 | [hpack]: https://github.com/sol/hpack 35 | 36 | ## Demo 37 | 38 | Check out an [asciinema demo][asciinema] to see how ghciwatch feels in practice: 39 | 40 | 41 | 42 | [asciinema]: https://asciinema.org/a/659712 43 | -------------------------------------------------------------------------------- /.github/workflows/label-prs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # This workflow runs when PRs are opened and labels them `patch`. 3 | 4 | on: 5 | pull_request_target: 6 | types: 7 | - opened 8 | - reopened 9 | 10 | name: Label PRs with `patch` by default 11 | 12 | jobs: 13 | # It seems like GitHub doesn't correctly populate the PR labels for the 14 | # `opened` event, so we use the GitHub API to fetch them separately. 15 | 16 | get-labels: 17 | name: Get PR labels 18 | runs-on: ubuntu-latest 19 | if: > 20 | ! startsWith(github.event.pull_request.head.ref, 'release/') 21 | outputs: 22 | labels: ${{ steps.get-labels.outputs.labels }} 23 | steps: 24 | - name: Get PR labels from GitHub API 25 | id: get-labels 26 | env: 27 | REPO: ${{ github.repository }} 28 | NUMBER: ${{ github.event.pull_request.number }} 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | run: | 31 | LABELS=$(gh api "repos/$REPO/issues/$NUMBER/labels") 32 | echo "PR #$NUMBER is labeled with: $LABELS" 33 | echo "labels=$LABELS" >> "$GITHUB_OUTPUT" 34 | 35 | label: 36 | name: Label PR with `patch` 37 | needs: 38 | - get-labels 39 | # This has been endlessly frustrating. I have no clue why I've had such bad 40 | # luck with this particular `if`, especially when I use the same logic 41 | # elsewhere in these actions and it seems to Just Work there. Misery! 42 | # Misery for Rebecca for 1000 years!!! 43 | # 44 | # total_hours_wasted_here = 4 45 | if: > 46 | ! ( contains(fromJSON(needs.get-labels.outputs.labels).*.name, 'release') 47 | || contains(fromJSON(needs.get-labels.outputs.labels).*.name, 'minor') 48 | || contains(fromJSON(needs.get-labels.outputs.labels).*.name, 'major') 49 | ) 50 | 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Label PR with `patch` 54 | uses: actions/labeler@ac9175f8a1f3625fd0d4fb234536d26811351594 # v4.3.0 55 | with: 56 | repo-token: ${{ secrets.GITHUB_TOKEN }} 57 | -------------------------------------------------------------------------------- /src/fake_reader.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | use std::pin::Pin; 3 | use std::task::Context; 4 | use std::task::Poll; 5 | 6 | use tokio::io::AsyncRead; 7 | use tokio::io::ReadBuf; 8 | 9 | /// A fake [`AsyncRead`] implementation for testing. 10 | /// 11 | /// A `FakeReader` contains a number of "chunks" of bytes. When [`AsyncRead::poll_read`] is called, 12 | /// the next chunk is delivered. This allows testers to supply all the data that can be read out of 13 | /// this reader up front while simulating streaming conditions where not all of the data can be 14 | /// read at once. 15 | #[derive(Debug, Default, Clone)] 16 | pub struct FakeReader { 17 | chunks: VecDeque>, 18 | } 19 | 20 | impl FakeReader { 21 | /// Construct a `FakeReader` from an iterator of strings. 22 | pub fn with_byte_chunks(chunks: [&[u8]; N]) -> Self { 23 | Self { 24 | chunks: chunks.into_iter().map(|chunk| chunk.to_vec()).collect(), 25 | } 26 | } 27 | } 28 | 29 | impl AsyncRead for FakeReader { 30 | fn poll_read( 31 | mut self: Pin<&mut Self>, 32 | _cx: &mut Context<'_>, 33 | buf: &mut ReadBuf<'_>, 34 | ) -> Poll> { 35 | match self.chunks.pop_front() { 36 | Some(mut chunk) => { 37 | let remaining = buf.remaining(); 38 | if chunk.len() <= remaining { 39 | // Write the whole chunk. 40 | buf.put_slice(&chunk); 41 | } else { 42 | // Write `remaining` bytes of the chunk, and reinsert the rest of it at the 43 | // front of the deque. 44 | let rest = chunk.split_off(remaining); 45 | buf.put_slice(&chunk); 46 | self.chunks.push_front(rest); 47 | } 48 | Poll::Ready(Ok(())) 49 | } 50 | None => { 51 | // Ok(()) without writing any data means EOF. 52 | Poll::Ready(Ok(())) 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /test-harness/src/tracing_reader.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::time::Duration; 3 | 4 | use backoff::backoff::Backoff; 5 | use backoff::ExponentialBackoff; 6 | use miette::Context; 7 | use miette::IntoDiagnostic; 8 | use tokio::fs::File; 9 | use tokio::io::AsyncBufReadExt; 10 | use tokio::io::BufReader; 11 | use tokio::io::Lines; 12 | 13 | use super::Event; 14 | 15 | /// A task to read JSON tracing log events output by `ghid-ng` and send them over a channel. 16 | pub struct TracingReader { 17 | lines: Lines>, 18 | } 19 | 20 | impl TracingReader { 21 | /// Create a new [`TracingReader`]. 22 | /// 23 | /// This watches for data to be read from the given `path`. When a line is written to `path` 24 | /// (by `ghciwatch`), the `TracingReader` will deserialize the line from JSON into an [`Event`] 25 | /// and send it to the given `sender` for another task to receive. 26 | pub async fn new(path: impl AsRef) -> miette::Result { 27 | let path = path.as_ref(); 28 | 29 | let file = File::open(path) 30 | .await 31 | .into_diagnostic() 32 | .wrap_err_with(|| format!("Failed to open {path:?}"))?; 33 | 34 | let lines = BufReader::new(file).lines(); 35 | 36 | Ok(Self { lines }) 37 | } 38 | 39 | /// Read the next event from the contained file. 40 | /// 41 | /// This will block indefinitely until a line is written to the contained file. 42 | pub async fn next_event(&mut self) -> miette::Result { 43 | let mut backoff = ExponentialBackoff { 44 | max_elapsed_time: None, 45 | max_interval: Duration::from_secs(1), 46 | ..Default::default() 47 | }; 48 | 49 | while let Some(duration) = backoff.next_backoff() { 50 | if let Some(line) = self.lines.next_line().await.into_diagnostic()? { 51 | let event = serde_json::from_str(&line) 52 | .into_diagnostic() 53 | .wrap_err_with(|| format!("Failed to deserialize JSON: {line}"))?; 54 | return Ok(event); 55 | } 56 | tokio::time::sleep(duration).await; 57 | } 58 | 59 | unreachable!() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test-harness/src/matcher/or_matcher.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::Event; 4 | use crate::Matcher; 5 | 6 | /// A [`Matcher`] that can match either of two other matchers. 7 | #[derive(Clone)] 8 | pub struct OrMatcher(pub A, pub B); 9 | 10 | impl Display for OrMatcher 11 | where 12 | A: Display, 13 | B: Display, 14 | { 15 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 16 | write!(f, "{} or {}", self.0, self.1) 17 | } 18 | } 19 | 20 | impl Matcher for OrMatcher 21 | where 22 | A: Display + Matcher, 23 | B: Display + Matcher, 24 | { 25 | fn matches(&mut self, event: &Event) -> miette::Result { 26 | // There may be some overlap in the events these matchers require to complete, so we 27 | // eagerly evaluate both matchers before combining the boolean result. 28 | let match_a = self.0.matches(event)?; 29 | let match_b = self.1.matches(event)?; 30 | Ok(match_a || match_b) 31 | } 32 | } 33 | 34 | #[cfg(test)] 35 | mod tests { 36 | use tracing::Level; 37 | 38 | use crate::tracing_json::Span; 39 | use crate::IntoMatcher; 40 | 41 | use super::*; 42 | 43 | #[test] 44 | fn test_or_matcher() { 45 | let mut matcher = "puppy" 46 | .into_matcher() 47 | .unwrap() 48 | .or("doggy".into_matcher().unwrap()); 49 | let event = Event { 50 | message: "puppy".to_owned(), 51 | timestamp: "2023-08-25T22:14:30.067641Z".to_owned(), 52 | level: Level::INFO, 53 | fields: Default::default(), 54 | target: "ghciwatch::ghci".to_owned(), 55 | span: Some(Span { 56 | name: "ghci".to_owned(), 57 | fields: Default::default(), 58 | }), 59 | spans: vec![Span { 60 | name: "ghci".to_owned(), 61 | fields: Default::default(), 62 | }], 63 | }; 64 | 65 | assert!(matcher.matches(&event).unwrap()); 66 | assert!(matcher 67 | .matches(&Event { 68 | message: "doggy".to_owned(), 69 | ..event 70 | }) 71 | .unwrap()); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test-harness/src/matcher/and_matcher.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::Event; 4 | use crate::Matcher; 5 | 6 | /// A [`Matcher`] that can match either of two other matchers. 7 | #[derive(Clone)] 8 | pub struct AndMatcher(pub A, pub B); 9 | 10 | impl Display for AndMatcher 11 | where 12 | A: Display, 13 | B: Display, 14 | { 15 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 16 | write!(f, "{} and {}", self.0, self.1) 17 | } 18 | } 19 | 20 | impl Matcher for AndMatcher 21 | where 22 | A: Display + Matcher, 23 | B: Display + Matcher, 24 | { 25 | fn matches(&mut self, event: &Event) -> miette::Result { 26 | // There may be some overlap in the events these matchers require to complete, so 27 | // we eagerly evaluate both matchers before combining the boolean result. 28 | let match_a = self.0.matches(event)?; 29 | let match_b = self.1.matches(event)?; 30 | Ok(match_a && match_b) 31 | } 32 | } 33 | 34 | #[cfg(test)] 35 | mod tests { 36 | use tracing::Level; 37 | 38 | use crate::tracing_json::Span; 39 | use crate::IntoMatcher; 40 | 41 | use super::*; 42 | 43 | #[test] 44 | fn test_and_matcher() { 45 | let mut matcher = "puppy" 46 | .into_matcher() 47 | .unwrap() 48 | .and("doggy".into_matcher().unwrap()); 49 | let event = Event { 50 | message: "puppy".to_owned(), 51 | timestamp: "2023-08-25T22:14:30.067641Z".to_owned(), 52 | level: Level::INFO, 53 | fields: Default::default(), 54 | target: "ghciwatch::ghci".to_owned(), 55 | span: Some(Span { 56 | name: "ghci".to_owned(), 57 | fields: Default::default(), 58 | }), 59 | spans: vec![Span { 60 | name: "ghci".to_owned(), 61 | fields: Default::default(), 62 | }], 63 | }; 64 | 65 | assert!(!matcher.matches(&event).unwrap()); 66 | assert!(matcher 67 | .matches(&Event { 68 | message: "doggy".to_owned(), 69 | ..event 70 | }) 71 | .unwrap()); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/ghci/ghci_command.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | use std::fmt::Display; 3 | use std::ops::Deref; 4 | 5 | use clap::builder::StringValueParser; 6 | use clap::builder::TypedValueParser; 7 | use clap::builder::ValueParserFactory; 8 | 9 | /// A `ghci` command. 10 | /// 11 | /// This is a string that can be written to a `ghci` session, typically a Haskell expression or 12 | /// `ghci` command starting with `:`. 13 | #[derive(Clone, PartialEq, Eq)] 14 | pub struct GhciCommand(pub String); 15 | 16 | impl GhciCommand { 17 | /// Consume this command, producing the wrapped string. 18 | pub fn into_string(self) -> String { 19 | self.0 20 | } 21 | } 22 | 23 | impl Debug for GhciCommand { 24 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 25 | Debug::fmt(&self.0, f) 26 | } 27 | } 28 | 29 | impl Display for GhciCommand { 30 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 31 | Display::fmt(&self.0, f) 32 | } 33 | } 34 | 35 | impl From for GhciCommand { 36 | fn from(value: String) -> Self { 37 | Self(value) 38 | } 39 | } 40 | 41 | impl From for String { 42 | fn from(value: GhciCommand) -> Self { 43 | value.into_string() 44 | } 45 | } 46 | 47 | impl AsRef for GhciCommand { 48 | fn as_ref(&self) -> &str { 49 | &self.0 50 | } 51 | } 52 | 53 | impl Deref for GhciCommand { 54 | type Target = str; 55 | 56 | fn deref(&self) -> &Self::Target { 57 | &self.0 58 | } 59 | } 60 | 61 | /// [`clap`] parser for [`GhciCommand`] values. 62 | #[derive(Default, Clone)] 63 | pub struct GhciCommandParser { 64 | inner: StringValueParser, 65 | } 66 | 67 | impl TypedValueParser for GhciCommandParser { 68 | type Value = GhciCommand; 69 | 70 | fn parse_ref( 71 | &self, 72 | cmd: &clap::Command, 73 | arg: Option<&clap::Arg>, 74 | value: &std::ffi::OsStr, 75 | ) -> Result { 76 | self.inner.parse_ref(cmd, arg, value).map(GhciCommand) 77 | } 78 | } 79 | 80 | impl ValueParserFactory for GhciCommand { 81 | type Parser = GhciCommandParser; 82 | 83 | fn value_parser() -> Self::Parser { 84 | Self::Parser::default() 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/clap/fmt_span.rs: -------------------------------------------------------------------------------- 1 | //! Adapter for pasing the [`FmtSpan`] type. 2 | 3 | use clap::builder::EnumValueParser; 4 | use clap::builder::PossibleValue; 5 | use clap::builder::TypedValueParser; 6 | use clap::builder::ValueParserFactory; 7 | use tracing_subscriber::fmt::format::FmtSpan; 8 | 9 | /// Wrapper around [`FmtSpan`]. 10 | #[derive(Clone)] 11 | pub struct FmtSpanWrapper(FmtSpan); 12 | 13 | impl From for FmtSpan { 14 | fn from(value: FmtSpanWrapper) -> Self { 15 | value.0 16 | } 17 | } 18 | 19 | impl clap::ValueEnum for FmtSpanWrapper { 20 | fn value_variants<'a>() -> &'a [Self] { 21 | &[ 22 | Self(FmtSpan::NEW), 23 | Self(FmtSpan::ENTER), 24 | Self(FmtSpan::EXIT), 25 | Self(FmtSpan::CLOSE), 26 | Self(FmtSpan::NONE), 27 | Self(FmtSpan::ACTIVE), 28 | Self(FmtSpan::FULL), 29 | ] 30 | } 31 | 32 | fn to_possible_value(&self) -> Option { 33 | Some(match self.0 { 34 | FmtSpan::NEW => PossibleValue::new("new").help("Log when spans are created"), 35 | FmtSpan::ENTER => PossibleValue::new("enter").help("Log when spans are entered"), 36 | FmtSpan::EXIT => PossibleValue::new("exit").help("Log when spans are exited"), 37 | FmtSpan::CLOSE => PossibleValue::new("close").help("Log when spans are dropped"), 38 | FmtSpan::NONE => PossibleValue::new("none").help("Do not log span events"), 39 | FmtSpan::ACTIVE => { 40 | PossibleValue::new("active").help("Log when spans are entered/exited") 41 | } 42 | FmtSpan::FULL => PossibleValue::new("full").help("Log all span events"), 43 | _ => { 44 | return None; 45 | } 46 | }) 47 | } 48 | } 49 | 50 | /// [`clap`] parser factory for [`FmtSpan`] values. 51 | pub struct FmtSpanParserFactory; 52 | 53 | impl ValueParserFactory for FmtSpanParserFactory { 54 | type Parser = clap::builder::MapValueParser< 55 | EnumValueParser, 56 | fn(FmtSpanWrapper) -> FmtSpan, 57 | >; 58 | 59 | fn value_parser() -> Self::Parser { 60 | EnumValueParser::::new().map(Into::into) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/shutdown.rs: -------------------------------------------------------------------------------- 1 | use nix::sys::signal; 2 | use nix::sys::signal::Signal; 3 | use nix::unistd::Pid; 4 | use test_harness::test; 5 | use test_harness::BaseMatcher; 6 | use test_harness::GhciWatch; 7 | use test_harness::JsonValue; 8 | 9 | /// Test that `ghciwatch` can gracefully shutdown on Ctrl-C. 10 | #[test] 11 | async fn can_shutdown_gracefully() { 12 | let mut session = GhciWatch::new("tests/data/simple") 13 | .await 14 | .expect("ghciwatch starts"); 15 | session 16 | .wait_until_ready() 17 | .await 18 | .expect("ghciwatch loads ghci"); 19 | 20 | signal::kill(Pid::from_raw(session.pid() as i32), Signal::SIGINT) 21 | .expect("Failed to send Ctrl-C to ghciwatch"); 22 | 23 | session 24 | .wait_for_log("^All tasks completed successfully$") 25 | .await 26 | .unwrap(); 27 | 28 | let status = session.wait_until_exit().await.unwrap(); 29 | assert!(status.success(), "ghciwatch exits successfully"); 30 | } 31 | 32 | /// Test that `ghciwatch` can gracefully shutdown when the `ghci` process is unexpectedly killed. 33 | #[test] 34 | async fn can_shutdown_gracefully_when_ghci_killed() { 35 | let mut session = GhciWatch::new("tests/data/simple") 36 | .await 37 | .expect("ghciwatch starts"); 38 | 39 | let event = session 40 | .wait_for_log(BaseMatcher::message("^Started ghci$")) 41 | .await 42 | .expect("ghciwatch starts ghci"); 43 | let pid: i32 = match event.fields.get("pid").unwrap() { 44 | JsonValue::Number(pid) => pid, 45 | value => { 46 | panic!("pid field has wrong type: {value:?}"); 47 | } 48 | } 49 | .as_i64() 50 | .expect("pid is i64") 51 | .try_into() 52 | .expect("pid is i32"); 53 | 54 | session 55 | .wait_until_ready() 56 | .await 57 | .expect("ghciwatch loads ghci"); 58 | 59 | signal::kill(Pid::from_raw(pid), Signal::SIGKILL).expect("Failed to kill ghci"); 60 | 61 | session 62 | .wait_for_log("^ghci exited:") 63 | .await 64 | .expect("ghci exits"); 65 | session.wait_for_log("^Shutdown requested$").await.unwrap(); 66 | session 67 | .wait_for_log("^All tasks completed successfully$") 68 | .await 69 | .unwrap(); 70 | } 71 | -------------------------------------------------------------------------------- /src/ghci/compilation_log.rs: -------------------------------------------------------------------------------- 1 | use crate::ghci::parse::CompilationResult; 2 | use crate::ghci::parse::CompilationSummary; 3 | use crate::ghci::parse::GhcDiagnostic; 4 | use crate::ghci::parse::GhcMessage; 5 | use crate::ghci::parse::Severity; 6 | 7 | /// A log of messages from compilation, used to write the error log. 8 | #[derive(Debug, Clone, Default)] 9 | pub struct CompilationLog { 10 | pub summary: Option, 11 | pub diagnostics: Vec, 12 | } 13 | 14 | impl CompilationLog { 15 | /// Get the result of compilation. 16 | pub fn result(&self) -> Option { 17 | self.summary.map(|summary| summary.result) 18 | } 19 | } 20 | 21 | impl Extend for CompilationLog { 22 | fn extend>(&mut self, iter: T) { 23 | for message in iter { 24 | match message { 25 | GhcMessage::Compiling(module) => { 26 | tracing::debug!(module = %module.name, path = %module.path, "Compiling"); 27 | } 28 | GhcMessage::Diagnostic(diagnostic) => { 29 | if let GhcDiagnostic { 30 | severity: Severity::Error, 31 | path: Some(path), 32 | message, 33 | .. 34 | } = &diagnostic 35 | { 36 | // We can't use 'message' for the field name because that's what tracing uses 37 | // for the message. 38 | tracing::debug!(%path, error = message, "Module failed to compile"); 39 | } 40 | self.diagnostics.push(diagnostic); 41 | } 42 | GhcMessage::Summary(summary) => { 43 | self.summary = Some(summary); 44 | match summary.result { 45 | CompilationResult::Ok => { 46 | tracing::debug!("Compilation succeeded"); 47 | } 48 | CompilationResult::Err => { 49 | tracing::debug!("Compilation failed"); 50 | } 51 | } 52 | } 53 | _ => {} 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ghci/parse/show_targets.rs: -------------------------------------------------------------------------------- 1 | use miette::miette; 2 | use winnow::combinator::repeat; 3 | use winnow::Parser; 4 | 5 | use crate::ghci::ModuleSet; 6 | 7 | use super::lines::until_newline; 8 | use super::show_paths::ShowPaths; 9 | 10 | /// Parse `:show targets` output into a set of module source paths. 11 | pub fn parse_show_targets(search_paths: &ShowPaths, input: &str) -> miette::Result { 12 | let targets: Vec<_> = repeat(0.., until_newline) 13 | .parse(input) 14 | .map_err(|err| miette!("{err}"))?; 15 | 16 | targets 17 | .into_iter() 18 | .map(|target| search_paths.target_to_path(target)) 19 | .collect() 20 | } 21 | 22 | #[cfg(test)] 23 | mod tests { 24 | use std::collections::HashSet; 25 | 26 | use crate::ghci::loaded_module::LoadedModule; 27 | use crate::normal_path::NormalPath; 28 | 29 | use super::*; 30 | use camino::Utf8PathBuf; 31 | use indoc::indoc; 32 | use pretty_assertions::assert_eq; 33 | 34 | #[test] 35 | fn test_parse_show_targets() { 36 | let show_paths = ShowPaths { 37 | cwd: NormalPath::from_cwd("tests/data/simple") 38 | .unwrap() 39 | .absolute() 40 | .to_owned(), 41 | search_paths: vec![Utf8PathBuf::from("test"), Utf8PathBuf::from("src")], 42 | }; 43 | 44 | let normal_path = |p: &str| NormalPath::new(p, &show_paths.cwd).unwrap(); 45 | 46 | assert_eq!( 47 | parse_show_targets( 48 | &show_paths, 49 | indoc!( 50 | " 51 | src/MyLib.hs 52 | TestMain 53 | MyLib 54 | MyModule 55 | " 56 | ) 57 | ) 58 | .unwrap() 59 | .into_iter() 60 | .collect::>(), 61 | vec![ 62 | LoadedModule::new(normal_path("src/MyLib.hs")), 63 | LoadedModule::with_name(normal_path("test/TestMain.hs"), "TestMain".to_owned()), 64 | LoadedModule::with_name(normal_path("src/MyLib.hs"), "MyLib".to_owned()), 65 | LoadedModule::with_name(normal_path("src/MyModule.hs"), "MyModule".to_owned()), 66 | ] 67 | .into_iter() 68 | .collect() 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/clap_markdown/mod.rs: -------------------------------------------------------------------------------- 1 | //! Autogenerate Markdown documentation for clap command-line tools 2 | //! 3 | //! This is a vendored fork of Connor Gray's [`clap-markdown`][clap-markdown] 4 | //! crate, which seems to be unmaintained (as of 2024-05). 5 | //! 6 | //! [clap-markdown]: https://github.com/ConnorGray/clap-markdown/ 7 | //! 8 | //! Major changes include: 9 | //! 10 | //! - Arguments are listed in a [`
` description list][dl] instead of a bulleted list. 11 | //! 12 | //! mdBook's Markdown renderer, pulldown-cmark, [doesn't support description 13 | //! lists][pulldown-cmark-67], so we have to generate [raw inline 14 | //! HTML][commonmark-html]. This makes the Markdown output much less pretty, but 15 | //! looks great rendered in the ghciwatch user manual. 16 | //! 17 | //! - Arguments are wrapped in [``][anchor] links so that other parts 18 | //! of the manual can link to specific arguments. 19 | //! 20 | //! - Support for documenting subcommands has been removed, as it's not used here. 21 | //! 22 | //! This portion of the code (files in this directory) are Apache-2.0 or MIT licensed. 23 | //! 24 | //! [anchor]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#linking_to_an_element_on_the_same_page 25 | //! [dl]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dl 26 | //! [pulldown-cmark-67]: https://github.com/pulldown-cmark/pulldown-cmark/issues/67 27 | //! [commonmark-html]: https://spec.commonmark.org/0.31.2/#raw-html 28 | 29 | mod formatter; 30 | use formatter::Formatter; 31 | 32 | /// Format the help information for `command` as Markdown. 33 | pub fn help_markdown() -> String { 34 | let command = C::command(); 35 | 36 | help_markdown_command(&command) 37 | } 38 | 39 | /// Format the help information for `command` as Markdown. 40 | pub fn help_markdown_command(command: &clap::Command) -> String { 41 | let mut buffer = String::with_capacity(2048); 42 | 43 | Formatter::new(&mut buffer, command).write().unwrap(); 44 | 45 | buffer 46 | } 47 | 48 | /// Format the help information for `command` as Markdown and print it. 49 | /// 50 | /// Output is printed to the standard output, using [`println!`]. 51 | pub fn print_help_markdown() { 52 | let command = C::command(); 53 | 54 | let markdown = help_markdown_command(&command); 55 | 56 | println!("{markdown}"); 57 | } 58 | -------------------------------------------------------------------------------- /test-harness/src/matcher/fused_matcher.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::Matcher; 4 | 5 | /// Wraps another [`Matcher`] and stops calling [`Matcher::matches`] on it after it first returns 6 | /// `true`. 7 | #[derive(Clone)] 8 | pub struct FusedMatcher { 9 | inner: M, 10 | matched: bool, 11 | } 12 | 13 | impl FusedMatcher { 14 | pub fn new(inner: M) -> Self { 15 | Self { 16 | inner, 17 | matched: false, 18 | } 19 | } 20 | } 21 | 22 | impl Display for FusedMatcher { 23 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 24 | write!(f, "{}", self.inner) 25 | } 26 | } 27 | 28 | impl Matcher for FusedMatcher { 29 | fn matches(&mut self, event: &crate::Event) -> miette::Result { 30 | if self.matched { 31 | Ok(true) 32 | } else { 33 | let res = self.inner.matches(event)?; 34 | self.matched = res; 35 | Ok(res) 36 | } 37 | } 38 | } 39 | 40 | #[cfg(test)] 41 | mod tests { 42 | use tracing::Level; 43 | 44 | use crate::tracing_json::Span; 45 | use crate::Event; 46 | use crate::IntoMatcher; 47 | 48 | use super::*; 49 | 50 | #[test] 51 | fn test_fused_matcher() { 52 | let mut matcher = "puppy".into_matcher().unwrap().fused(); 53 | let event = Event { 54 | message: "puppy".to_owned(), 55 | timestamp: "2023-08-25T22:14:30.067641Z".to_owned(), 56 | level: Level::INFO, 57 | fields: Default::default(), 58 | target: "ghciwatch::ghci".to_owned(), 59 | span: Some(Span { 60 | name: "ghci".to_owned(), 61 | fields: Default::default(), 62 | }), 63 | spans: vec![Span { 64 | name: "ghci".to_owned(), 65 | fields: Default::default(), 66 | }], 67 | }; 68 | 69 | assert!(!matcher 70 | .matches(&Event { 71 | message: "doggy".to_owned(), 72 | ..event.clone() 73 | }) 74 | .unwrap()); 75 | assert!(matcher.matches(&event).unwrap()); 76 | assert!(matcher 77 | .matches(&Event { 78 | message: "doggy".to_owned(), 79 | ..event 80 | }) 81 | .unwrap()); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/reload.rs: -------------------------------------------------------------------------------- 1 | use indoc::indoc; 2 | 3 | use test_harness::test; 4 | use test_harness::BaseMatcher; 5 | use test_harness::GhciWatch; 6 | 7 | /// Test that `ghciwatch` can start up and then reload on changes. 8 | #[test] 9 | async fn can_reload() { 10 | let mut session = GhciWatch::new("tests/data/simple") 11 | .await 12 | .expect("ghciwatch starts"); 13 | session 14 | .wait_until_ready() 15 | .await 16 | .expect("ghciwatch loads ghci"); 17 | session 18 | .fs() 19 | .append( 20 | session.path("src/MyLib.hs"), 21 | indoc!( 22 | " 23 | 24 | hello = 1 :: Integer 25 | 26 | " 27 | ), 28 | ) 29 | .await 30 | .unwrap(); 31 | session 32 | .wait_until_reload() 33 | .await 34 | .expect("ghciwatch reloads on changes"); 35 | session 36 | .wait_for_log(BaseMatcher::reload_completes()) 37 | .await 38 | .expect("ghciwatch finishes reloading"); 39 | } 40 | 41 | /// Test that `ghciwatch` can reload a module that fails to compile. 42 | #[test] 43 | async fn can_reload_after_error() { 44 | let mut session = GhciWatch::new("tests/data/simple") 45 | .await 46 | .expect("ghciwatch starts"); 47 | session 48 | .wait_until_ready() 49 | .await 50 | .expect("ghciwatch loads ghci"); 51 | let new_module = session.path("src/My/Module.hs"); 52 | 53 | session 54 | .fs() 55 | .write( 56 | &new_module, 57 | indoc!( 58 | "module My.Module (myIdent) where 59 | myIdent :: () 60 | myIdent = \"Uh oh!\" 61 | " 62 | ), 63 | ) 64 | .await 65 | .unwrap(); 66 | session 67 | .wait_until_add() 68 | .await 69 | .expect("ghciwatch loads new modules"); 70 | session 71 | .wait_for_log(BaseMatcher::compilation_failed()) 72 | .await 73 | .unwrap(); 74 | 75 | session 76 | .fs() 77 | .replace(&new_module, "myIdent = \"Uh oh!\"", "myIdent = ()") 78 | .await 79 | .unwrap(); 80 | 81 | session 82 | .wait_until_reload() 83 | .await 84 | .expect("ghciwatch reloads on changes"); 85 | session 86 | .wait_for_log(BaseMatcher::compilation_succeeded()) 87 | .await 88 | .unwrap(); 89 | } 90 | -------------------------------------------------------------------------------- /docs/comment-evaluation.md: -------------------------------------------------------------------------------- 1 | # Comment evaluation 2 | 3 | With the [`--enable-eval`](cli.md#--enable-eval) flag set, ghciwatch will 4 | execute Haskell code in comments which start with `$>` in GHCi. 5 | 6 | ```haskell 7 | myGreeting :: String 8 | myGreeting = "Hello" 9 | 10 | -- $> putStrLn (myGreeting <> " " <> myGreeting) 11 | ``` 12 | 13 | Prints: 14 | 15 | ``` 16 | • src/MyLib.hs:9:7: putStrLn (myGreeting <> " " <> myGreeting) 17 | Hello Hello 18 | ``` 19 | 20 | ## Running tests with eval comments 21 | 22 | Eval comments can be used to run tests in a single file on reload. For large 23 | test suites (thousands of tests), this can be much faster than using [Hspec's 24 | `--match` option][hspec-match], because `--match` has to load the entire test 25 | suite and perform string matches on `[Char]` to determine which tests should be 26 | run. (Combine this with Cabal's [`--repl-no-load`](no-load.md) option to only 27 | load the modules your test depends on for even faster reloads.) 28 | 29 | ```haskell 30 | module MyLibSpec (spec) where 31 | 32 | import Test.Hspec 33 | import MyLib (myGreeting) 34 | 35 | -- $> import Test.Hspec -- May be necessary for some setups. 36 | -- $> hspec spec 37 | 38 | spec :: Spec 39 | spec = do 40 | describe "myGreeting" $ do 41 | it "is hello" $ do 42 | myGreeting `shouldBe` "Hello" 43 | ``` 44 | 45 | 46 | [hspec-match]: https://hspec.github.io/match.html 47 | [test.hspec]: https://hackage.haskell.org/package/hspec/docs/Test-Hspec.html 48 | [spec]: https://hackage.haskell.org/package/hspec/docs/Test-Hspec.html#t:Spec 49 | 50 | ## Grammar 51 | 52 | Single-line eval comments have the following grammar: 53 | 54 | ``` 55 | [ \t]* # Leading whitespace 56 | "-- $>" # Eval comment marker 57 | [ \t]* # Optional whitespace 58 | [^\n]+ \n # Rest of line 59 | ``` 60 | 61 | Multi-line eval comments have the following grammar: 62 | 63 | ``` 64 | [ \t]* # Leading whitespace 65 | "{- $>" # Eval comment marker 66 | ([ \t]* \n)? # Optional newline 67 | ([^\n]* \n)* # Lines of Haskell code 68 | [ \t]* # Optional whitespace 69 | "<$ -}" # Eval comment end marker 70 | ``` 71 | 72 | 73 | ## Performance implications 74 | 75 | Note that because each loaded module must be read (and re-read when it changes) 76 | to parse eval comments, enabling this feature has some performance overhead. 77 | (It's probably not too bad, because all those files are in your disk cache 78 | anyways from being compiled by GHCi.) 79 | -------------------------------------------------------------------------------- /src/ghci/parse/ghc_message/cant_find_file_diagnostic.rs: -------------------------------------------------------------------------------- 1 | use camino::Utf8PathBuf; 2 | use winnow::ascii::space1; 3 | use winnow::PResult; 4 | use winnow::Parser; 5 | 6 | use crate::ghci::parse::lines::until_newline; 7 | 8 | use crate::ghci::parse::ghc_message::position; 9 | use crate::ghci::parse::ghc_message::severity; 10 | 11 | use super::GhcDiagnostic; 12 | 13 | /// Parse a "can't find file" message like this: 14 | /// 15 | /// ```plain 16 | /// : error: can't find file: Why.hs 17 | /// ``` 18 | pub fn cant_find_file_diagnostic(input: &mut &str) -> PResult { 19 | let _ = position::parse_unhelpful_position.parse_next(input)?; 20 | let _ = space1.parse_next(input)?; 21 | let severity = severity::parse_severity_colon.parse_next(input)?; 22 | let _ = space1.parse_next(input)?; 23 | let _ = "can't find file: ".parse_next(input)?; 24 | let path = until_newline.parse_next(input)?; 25 | 26 | Ok(GhcDiagnostic { 27 | severity, 28 | path: Some(Utf8PathBuf::from(path)), 29 | span: Default::default(), 30 | message: "can't find file".to_owned(), 31 | }) 32 | } 33 | 34 | #[cfg(test)] 35 | mod tests { 36 | use super::*; 37 | 38 | use indoc::indoc; 39 | use pretty_assertions::assert_eq; 40 | use severity::Severity; 41 | 42 | #[test] 43 | fn test_parse_cant_find_file_message() { 44 | assert_eq!( 45 | cant_find_file_diagnostic 46 | .parse(": error: can't find file: Why.hs\n") 47 | .unwrap(), 48 | GhcDiagnostic { 49 | severity: Severity::Error, 50 | path: Some("Why.hs".into()), 51 | span: Default::default(), 52 | message: "can't find file".to_owned() 53 | } 54 | ); 55 | 56 | // Doesn't parse another error message. 57 | assert!(cant_find_file_diagnostic 58 | .parse(indoc!( 59 | " 60 | : error: can't find file: Why.hs 61 | Error: Uh oh! 62 | " 63 | )) 64 | .is_err()); 65 | } 66 | 67 | #[test] 68 | fn test_cant_find_file_display() { 69 | assert_eq!( 70 | cant_find_file_diagnostic 71 | .parse(": error: can't find file: Why.hs\n") 72 | .unwrap() 73 | .to_string(), 74 | "Why.hs: error: can't find file" 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/event_filter.rs: -------------------------------------------------------------------------------- 1 | //! Parsing [`DebouncedEvent`]s into changes `ghciwatch` can respond to. 2 | 3 | use std::collections::BTreeSet; 4 | 5 | use camino::Utf8Path; 6 | use camino::Utf8PathBuf; 7 | use miette::IntoDiagnostic; 8 | use notify_debouncer_full::notify::EventKind; 9 | use notify_debouncer_full::DebouncedEvent; 10 | 11 | /// A set of filesystem events that `ghci` will need to respond to. Due to the way that `ghci` is, 12 | /// we need to divide these into a few different classes so that we can respond appropriately. 13 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 14 | pub enum FileEvent { 15 | /// Existing files that are modified, or new files that are created. 16 | /// 17 | /// `inotify` APIs aren't great at distinguishing between newly-created files and modified 18 | /// existing files (particularly because some editors, like `vim`, will write to a temporary 19 | /// file and then move that file over the original for atomicity), so this includes both sorts 20 | /// of changes. 21 | Modify(Utf8PathBuf), 22 | /// A file is removed. 23 | Remove(Utf8PathBuf), 24 | } 25 | 26 | impl FileEvent { 27 | /// Get the contained path. 28 | pub fn as_path(&self) -> &Utf8Path { 29 | match self { 30 | FileEvent::Modify(path) => path.as_path(), 31 | FileEvent::Remove(path) => path.as_path(), 32 | } 33 | } 34 | } 35 | 36 | /// Process a set of events into a set of [`FileEvent`]s. 37 | pub fn file_events_from_action(events: Vec) -> miette::Result> { 38 | let mut ret = BTreeSet::new(); 39 | 40 | for event in events { 41 | let event = event.event; 42 | let mut modified = false; 43 | let mut removed = false; 44 | match event.kind { 45 | EventKind::Remove(_) => { 46 | removed = true; 47 | } 48 | 49 | EventKind::Any | EventKind::Other | EventKind::Create(_) | EventKind::Modify(_) => { 50 | modified = true; 51 | } 52 | 53 | EventKind::Access(_) => { 54 | // Non-mutating event, ignore these. 55 | } 56 | } 57 | 58 | for path in event.paths { 59 | let path: Utf8PathBuf = path.try_into().into_diagnostic()?; 60 | 61 | if !path.exists() || removed { 62 | ret.insert(FileEvent::Remove(path)); 63 | } else if modified { 64 | ret.insert(FileEvent::Modify(path)); 65 | } 66 | } 67 | } 68 | 69 | Ok(ret) 70 | } 71 | -------------------------------------------------------------------------------- /test-harness/src/matcher/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::Event; 4 | 5 | mod span_matcher; 6 | pub use span_matcher::SpanMatcher; 7 | 8 | mod field_matcher; 9 | pub(crate) use field_matcher::FieldMatcher; 10 | 11 | mod into_matcher; 12 | pub use into_matcher::IntoMatcher; 13 | 14 | mod base_matcher; 15 | pub use base_matcher::BaseMatcher; 16 | 17 | mod or_matcher; 18 | pub use or_matcher::OrMatcher; 19 | 20 | mod and_matcher; 21 | pub use and_matcher::AndMatcher; 22 | 23 | mod fused_matcher; 24 | pub use fused_matcher::FusedMatcher; 25 | 26 | mod option_matcher; 27 | pub use option_matcher::OptionMatcher; 28 | 29 | mod never_matcher; 30 | pub use never_matcher::NeverMatcher; 31 | 32 | mod negative_matcher; 33 | pub use negative_matcher::NegativeMatcher; 34 | 35 | /// A type which can match log events. 36 | pub trait Matcher: Display { 37 | /// Feeds an event to the matcher and determines if the matcher has finished. 38 | /// 39 | /// Note that matchers may need multiple separate log messages to complete matching. 40 | fn matches(&mut self, event: &Event) -> miette::Result; 41 | 42 | /// Construct a matcher that matches when this matcher or the `other` matcher have 43 | /// finished matching. 44 | fn or(self, other: O) -> OrMatcher 45 | where 46 | O: Matcher, 47 | Self: Sized, 48 | { 49 | OrMatcher(self, other) 50 | } 51 | 52 | /// Construct a matcher that matches when this matcher and the `other` matcher have 53 | /// finished matching. 54 | fn and(self, other: O) -> AndMatcher, FusedMatcher> 55 | where 56 | O: Matcher, 57 | Self: Sized, 58 | { 59 | AndMatcher(self.fused(), other.fused()) 60 | } 61 | 62 | /// Construct a matcher that stops calling [`Matcher::matches`] on this matcher after it 63 | /// first returns `true`. 64 | fn fused(self) -> FusedMatcher 65 | where 66 | Self: Sized, 67 | { 68 | FusedMatcher::new(self) 69 | } 70 | 71 | /// Construct a matcher that matches when this matcher matches and errors when the `other` 72 | /// matcher matches. 73 | fn but_not(self, other: O) -> NegativeMatcher 74 | where 75 | O: Matcher, 76 | Self: Sized, 77 | { 78 | NegativeMatcher::new(self, other) 79 | } 80 | } 81 | 82 | impl Matcher for &mut M 83 | where 84 | M: Matcher, 85 | { 86 | fn matches(&mut self, event: &Event) -> miette::Result { 87 | (*self).matches(event) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/ghci/error_log.rs: -------------------------------------------------------------------------------- 1 | use camino::Utf8PathBuf; 2 | use miette::IntoDiagnostic; 3 | use tokio::fs::File; 4 | use tokio::io::AsyncWriteExt; 5 | use tokio::io::BufWriter; 6 | use tracing::instrument; 7 | 8 | use super::parse::CompilationResult; 9 | use super::parse::ModulesLoaded; 10 | use super::CompilationLog; 11 | 12 | /// Error log writer. 13 | /// 14 | /// This produces `ghcid`-compatible output, which can be consumed by `ghcid` plugins in your 15 | /// editor of choice. 16 | pub struct ErrorLog { 17 | path: Option, 18 | } 19 | 20 | impl ErrorLog { 21 | /// Construct a new error log writer for the given path. 22 | pub fn new(path: Option) -> Self { 23 | Self { path } 24 | } 25 | 26 | /// Write the error log, if any, with the given compilation summary and diagnostic messages. 27 | #[instrument(skip(self, log), name = "error_log_write", level = "debug")] 28 | pub async fn write(&mut self, log: &CompilationLog) -> miette::Result<()> { 29 | let path = match &self.path { 30 | Some(path) => path, 31 | None => { 32 | tracing::debug!("No error log path, not writing"); 33 | return Ok(()); 34 | } 35 | }; 36 | 37 | let file = File::create(path).await.into_diagnostic()?; 38 | let mut writer = BufWriter::new(file); 39 | 40 | if let Some(summary) = log.summary { 41 | // `ghcid` only writes the headline if there's no errors. 42 | if let CompilationResult::Ok = summary.result { 43 | tracing::debug!(%path, "Writing 'All good'"); 44 | let modules_loaded = if summary.modules_loaded != ModulesLoaded::Count(1) { 45 | format!("{} modules", summary.modules_loaded) 46 | } else { 47 | format!("{} module", summary.modules_loaded) 48 | }; 49 | writer 50 | .write_all(format!("All good ({modules_loaded})\n").as_bytes()) 51 | .await 52 | .into_diagnostic()?; 53 | } 54 | } 55 | 56 | for diagnostic in &log.diagnostics { 57 | tracing::debug!(%diagnostic, "Writing diagnostic"); 58 | writer 59 | .write_all(diagnostic.to_string().as_bytes()) 60 | .await 61 | .into_diagnostic()?; 62 | } 63 | 64 | // This is load-bearing! If we don't properly flush/shutdown the handle, nothing gets 65 | // written! 66 | writer.shutdown().await.into_diagnostic()?; 67 | 68 | Ok(()) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /test-harness/src/matcher/negative_matcher.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use miette::miette; 4 | 5 | use crate::Event; 6 | use crate::Matcher; 7 | 8 | /// Wraps two matchers. The first matcher is used as normal, except if the negative matcher 9 | /// matches an event, [`Matcher::matches`] errors. 10 | #[derive(Clone)] 11 | pub struct NegativeMatcher { 12 | inner: M, 13 | negative: N, 14 | } 15 | 16 | impl NegativeMatcher { 17 | /// Construct a matcher that matches if `inner` matches and errors if `negative` matches. 18 | pub fn new(inner: M, negative: N) -> Self { 19 | Self { inner, negative } 20 | } 21 | } 22 | 23 | impl Display for NegativeMatcher 24 | where 25 | A: Display, 26 | B: Display, 27 | { 28 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 29 | write!(f, "{} but not {}", self.inner, self.negative) 30 | } 31 | } 32 | 33 | impl Matcher for NegativeMatcher 34 | where 35 | A: Display + Matcher, 36 | B: Display + Matcher, 37 | { 38 | fn matches(&mut self, event: &Event) -> miette::Result { 39 | if self.negative.matches(event)? { 40 | Err(miette!("Log event matched {}: {}", self.negative, event)) 41 | } else if self.inner.matches(event)? { 42 | Ok(true) 43 | } else { 44 | Ok(false) 45 | } 46 | } 47 | } 48 | 49 | #[cfg(test)] 50 | mod tests { 51 | use tracing::Level; 52 | 53 | use crate::tracing_json::Span; 54 | use crate::IntoMatcher; 55 | 56 | use super::*; 57 | 58 | #[test] 59 | fn test_negative_matcher() { 60 | let event = Event { 61 | message: "puppy".to_owned(), 62 | timestamp: "2023-08-25T22:14:30.067641Z".to_owned(), 63 | level: Level::INFO, 64 | fields: Default::default(), 65 | target: "ghciwatch::ghci".to_owned(), 66 | span: Some(Span { 67 | name: "ghci".to_owned(), 68 | fields: Default::default(), 69 | }), 70 | spans: vec![Span { 71 | name: "ghci".to_owned(), 72 | fields: Default::default(), 73 | }], 74 | }; 75 | 76 | let mut matcher = "puppy" 77 | .into_matcher() 78 | .unwrap() 79 | .but_not("doggy".into_matcher().unwrap()); 80 | 81 | assert!(matcher.matches(&event).unwrap()); 82 | assert!(matcher 83 | .matches(&Event { 84 | message: "doggy".to_owned(), 85 | ..event 86 | }) 87 | .is_err()); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/ghci/parse/ghc_message/severity.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use winnow::combinator::dispatch; 4 | use winnow::combinator::empty; 5 | use winnow::combinator::fail; 6 | use winnow::combinator::terminated; 7 | use winnow::token::take_until; 8 | use winnow::PResult; 9 | use winnow::Parser; 10 | 11 | /// The severity of a compiler message. 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 13 | pub enum Severity { 14 | /// Warning-level; non-fatal. 15 | Warning, 16 | /// Error-level; fatal. 17 | Error, 18 | } 19 | 20 | impl Display for Severity { 21 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 22 | match self { 23 | Severity::Warning => write!(f, "warning"), 24 | Severity::Error => write!(f, "error"), 25 | } 26 | } 27 | } 28 | 29 | /// Parse a severity followed by a `:`, either `Warning` or `Error`. 30 | pub fn parse_severity_colon(input: &mut &str) -> PResult { 31 | terminated( 32 | dispatch! { take_until(1.., ":"); 33 | "warning"|"Warning" => empty.value(Severity::Warning), 34 | "error"|"Error" => empty.value(Severity::Error), 35 | _ => fail, 36 | }, 37 | ":", 38 | ) 39 | .parse_next(input) 40 | } 41 | 42 | #[cfg(test)] 43 | mod tests { 44 | use super::*; 45 | 46 | #[test] 47 | fn test_parse_severity() { 48 | assert_eq!( 49 | parse_severity_colon.parse("Warning:").unwrap(), 50 | Severity::Warning 51 | ); 52 | assert_eq!( 53 | parse_severity_colon.parse("warning:").unwrap(), 54 | Severity::Warning 55 | ); 56 | assert_eq!( 57 | parse_severity_colon.parse("Error:").unwrap(), 58 | Severity::Error 59 | ); 60 | assert_eq!( 61 | parse_severity_colon.parse("error:").unwrap(), 62 | Severity::Error 63 | ); 64 | 65 | // Negative cases. 66 | assert!(parse_severity_colon.parse(" Error:").is_err()); 67 | assert!(parse_severity_colon.parse("Error :").is_err()); 68 | assert!(parse_severity_colon.parse("Error: ").is_err()); 69 | assert!(parse_severity_colon.parse(" Warning:").is_err()); 70 | assert!(parse_severity_colon.parse("Warning :").is_err()); 71 | assert!(parse_severity_colon.parse("Warning: ").is_err()); 72 | assert!(parse_severity_colon.parse("W arning:").is_err()); 73 | } 74 | 75 | #[test] 76 | fn test_display() { 77 | assert_eq!(Severity::Error.to_string(), "error"); 78 | assert_eq!(Severity::Warning.to_string(), "warning"); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/ghci/process.rs: -------------------------------------------------------------------------------- 1 | use std::future::Future; 2 | use std::pin::Pin; 3 | use std::process::ExitStatus; 4 | 5 | use command_group::AsyncGroupChild; 6 | use miette::Context; 7 | use miette::IntoDiagnostic; 8 | use nix::sys::signal; 9 | use nix::sys::signal::Signal; 10 | use nix::unistd::Pid; 11 | use tokio::sync::mpsc; 12 | use tracing::instrument; 13 | 14 | use crate::shutdown::ShutdownHandle; 15 | 16 | pub struct GhciProcess { 17 | pub shutdown: ShutdownHandle, 18 | pub process_group_id: Pid, 19 | /// Notifies this task to _not_ request a shutdown for the entire program when `ghci` exits. 20 | /// This is used for the graceful shutdown implementation and for routine `ghci` session 21 | /// restarts. 22 | pub restart_receiver: mpsc::Receiver<()>, 23 | } 24 | 25 | impl GhciProcess { 26 | #[instrument(skip_all, name = "ghci_process", level = "debug")] 27 | pub async fn run(mut self, mut process: AsyncGroupChild) -> miette::Result<()> { 28 | // We can only call `wait()` once at a time, so we store the future and pass it into the 29 | // `stop()` handler. 30 | let mut wait = std::pin::pin!(process.wait()); 31 | tokio::select! { 32 | _ = self.shutdown.on_shutdown_requested() => { 33 | self.stop(wait).await?; 34 | } 35 | _ = self.restart_receiver.recv() => { 36 | tracing::debug!("ghci is being shut down"); 37 | self.stop(wait).await?; 38 | } 39 | result = &mut wait => { 40 | self.exited(result.into_diagnostic()?).await; 41 | let _ = self.shutdown.request_shutdown(); 42 | } 43 | } 44 | Ok(()) 45 | } 46 | 47 | #[instrument(skip_all, level = "debug")] 48 | async fn stop( 49 | &self, 50 | wait: Pin<&mut impl Future>>, 51 | ) -> miette::Result<()> { 52 | // Kill it otherwise. 53 | tracing::debug!("Killing ghci process tree with SIGKILL"); 54 | // This is what `self.process.kill()` does, but we can't call that due to borrow 55 | // checker shennanigans. 56 | signal::killpg(self.process_group_id, Signal::SIGKILL) 57 | .into_diagnostic() 58 | .wrap_err_with(|| { 59 | format!( 60 | "Failed to kill ghci process (pid {})", 61 | self.process_group_id 62 | ) 63 | })?; 64 | // Report the exit status. 65 | let status = wait.await.into_diagnostic()?; 66 | 67 | self.exited(status).await; 68 | Ok(()) 69 | } 70 | 71 | async fn exited(&self, status: ExitStatus) { 72 | tracing::debug!("ghci exited: {status}"); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /docs/integration/multiple-components.md: -------------------------------------------------------------------------------- 1 | # Multiple Cabal components 2 | 3 | Currently, multiple Cabal components don't work. You can work around this with 4 | [the Cabal `test-dev` trick][test-dev] as described by the venerable Jade 5 | Lovelace. This works by defining a new component in our `.cabal` file which 6 | includes the sources from the library and the tests, which has the added 7 | benefit of speeding up compile times by allowing the compilation of different 8 | components to be interleaved. 9 | 10 | [test-dev]: https://jade.fyi/blog/cabal-test-dev-trick/ 11 | 12 | You can [see this demonstrated in the ghciwatch test sources 13 | here][test-dev-in-ghciwatch]. We define four components: 14 | 15 | - `library` 16 | - `tests` 17 | - An internal `test-lib` library 18 | - An internal `test-dev` library 19 | 20 | [test-dev-in-ghciwatch]: https://github.com/MercuryTechnologies/ghciwatch/blob/93fbb67fba6abd3903596876394acf234cb9bdb2/tests/data/simple/package.yaml 21 | 22 | Then, we can use a command like `cabal v2-repl test-dev` to run a GHCi session 23 | containing both the library and test sources. 24 | 25 | The `package.yaml` should look something like this: 26 | 27 | ```yaml 28 | --- 29 | spec-version: 0.36.0 30 | name: my-simple-package 31 | version: 0.1.0.0 32 | 33 | flags: 34 | local-dev: 35 | description: Turn on development settings, like auto-reload templates. 36 | manual: true 37 | default: false 38 | 39 | library: 40 | source-dirs: src 41 | 42 | tests: 43 | test: 44 | main: Main.hs 45 | source-dirs: 46 | - test-main 47 | ghc-options: -threaded -rtsopts -with-rtsopts=-N 48 | when: 49 | - condition: flag(local-dev) 50 | then: 51 | dependencies: 52 | - test-dev 53 | else: 54 | dependencies: 55 | - my-simple-package 56 | - test-lib 57 | 58 | internal-libraries: 59 | test-lib: 60 | source-dirs: 61 | - test 62 | 63 | test-dev: 64 | source-dirs: 65 | - test 66 | - src 67 | when: 68 | - condition: flag(local-dev) 69 | then: 70 | buildable: true 71 | else: 72 | buildable: false 73 | ``` 74 | 75 | Then, we can set the `local-dev` flag in our `cabal.project.local`, so that we 76 | use the `test-dev` target locally: 77 | 78 | ```cabal 79 | package my-simple-package 80 | flags: +local-dev 81 | ``` 82 | 83 | 84 | ## haskell-language-server 85 | 86 | Defining the `test-dev` component does tend to confuse 87 | `haskell-language-server`, as a single file is now in multiple components. Fix 88 | this by writing [an `hie.yaml`][hie-yaml] like this: 89 | 90 | ```yaml 91 | cradle: 92 | cabal: 93 | component: test-dev 94 | ``` 95 | 96 | [hie-yaml]: https://haskell-language-server.readthedocs.io/en/stable/configuration.html#configuring-your-project-build 97 | -------------------------------------------------------------------------------- /src/ghci/loaded_module.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Borrow; 2 | use std::fmt::Display; 3 | use std::hash::Hash; 4 | use std::hash::Hasher; 5 | 6 | use camino::Utf8Path; 7 | 8 | use crate::normal_path::NormalPath; 9 | 10 | /// Information about a module loaded into a `ghci` session. 11 | /// 12 | /// Hashing and equality are determined by the module's path alone. 13 | #[derive(Debug, Clone, Eq)] 14 | pub struct LoadedModule { 15 | /// The module's source file, like `src/My/Cool/Module.hs`. 16 | path: NormalPath, 17 | 18 | /// The module's dotted name, like `My.Cool.Module`. 19 | /// 20 | /// This is present if and only if the module is loaded by name. 21 | /// 22 | /// Entries in `:show targets` can be one of two types: module paths or module names (with `.` in 23 | /// place of path separators). Due to a `ghci` bug, the module can only be referred to as whichever 24 | /// form it was originally added as (see below), so we use this to track how we refer to modules. 25 | /// 26 | /// See: 27 | name: Option, 28 | } 29 | 30 | impl LoadedModule { 31 | /// Create a new module, loaded by path. 32 | pub fn new(path: NormalPath) -> Self { 33 | Self { path, name: None } 34 | } 35 | 36 | /// Create a new module, loaded by name. 37 | pub fn with_name(path: NormalPath, name: String) -> Self { 38 | Self { 39 | path, 40 | name: Some(name), 41 | } 42 | } 43 | 44 | /// Get the module's source path. 45 | pub fn path(&self) -> &NormalPath { 46 | &self.path 47 | } 48 | } 49 | 50 | impl Display for LoadedModule { 51 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 52 | write!( 53 | f, 54 | "{}", 55 | self.name 56 | .as_deref() 57 | .unwrap_or_else(|| self.path.relative().as_str()) 58 | ) 59 | } 60 | } 61 | 62 | impl Hash for LoadedModule { 63 | fn hash(&self, state: &mut H) { 64 | self.path.hash(state) 65 | } 66 | } 67 | 68 | impl PartialEq for LoadedModule { 69 | fn eq(&self, other: &Self) -> bool { 70 | self.path.eq(&other.path) 71 | } 72 | } 73 | 74 | impl PartialOrd for LoadedModule { 75 | fn partial_cmp(&self, other: &Self) -> Option { 76 | Some(self.cmp(other)) 77 | } 78 | } 79 | 80 | impl Ord for LoadedModule { 81 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 82 | self.path.cmp(&other.path) 83 | } 84 | } 85 | 86 | impl Borrow for LoadedModule { 87 | fn borrow(&self) -> &NormalPath { 88 | &self.path 89 | } 90 | } 91 | 92 | impl Borrow for LoadedModule { 93 | fn borrow(&self) -> &Utf8Path { 94 | &self.path 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /test-harness/src/matcher/field_matcher.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fmt::Display; 3 | 4 | use itertools::Itertools; 5 | use regex::Regex; 6 | use serde_json::Value; 7 | 8 | /// A matcher for fields and values in key-value maps. 9 | /// 10 | /// Used for span and event fields. 11 | #[derive(Clone, Default)] 12 | pub struct FieldMatcher { 13 | fields: HashMap, 14 | } 15 | 16 | impl FieldMatcher { 17 | /// True if this matcher contains any fields. 18 | pub fn is_empty(&self) -> bool { 19 | self.fields.is_empty() 20 | } 21 | 22 | /// Require that matching objects contain a field with the given name and a value matching the 23 | /// given regex. 24 | /// 25 | /// ### Panics 26 | /// 27 | /// If the `value_regex` fails to compile. 28 | pub fn with_field(mut self, name: &str, value_regex: &str) -> Self { 29 | self.fields.insert( 30 | name.to_owned(), 31 | Regex::new(value_regex).expect("Value regex failed to compile"), 32 | ); 33 | self 34 | } 35 | 36 | /// True if the given field access function yields fields which validate this matcher. 37 | pub fn matches<'a>(&'a self, get_field: impl Fn(&'a str) -> Option<&'a Value>) -> bool { 38 | for (name, value_regex) in &self.fields { 39 | let value = get_field(name); 40 | match value { 41 | None => { 42 | // We expected the field to be present. 43 | return false; 44 | } 45 | Some(value) => { 46 | match value { 47 | Value::Null 48 | | Value::Bool(_) 49 | | Value::Number(_) 50 | | Value::Array(_) 51 | | Value::Object(_) => { 52 | // We expected the value to be a string. 53 | return false; 54 | } 55 | Value::String(value) => { 56 | if !value_regex.is_match(value) { 57 | // We expected the regex to match. 58 | return false; 59 | } 60 | } 61 | } 62 | } 63 | } 64 | } 65 | 66 | true 67 | } 68 | } 69 | 70 | impl Display for FieldMatcher { 71 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 72 | if self.fields.is_empty() { 73 | write!(f, "any fields")?; 74 | } else { 75 | write!( 76 | f, 77 | "with fields {}", 78 | self.fields 79 | .iter() 80 | .map(|(k, v)| format!("{k}={v:?}")) 81 | .join(", ") 82 | )?; 83 | } 84 | 85 | Ok(()) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /docs/no-load.md: -------------------------------------------------------------------------------- 1 | # Only load modules you need 2 | 3 | **TL;DR:** Use [`cabal repl --repl-no-load`][repl-no-load] to start a GHCi 4 | session with no modules loaded. Then, when you edit a module, ghciwatch will 5 | `:add` it to the GHCi session, causing only the modules you need (and their 6 | dependencies) to be loaded. In large projects, this can significantly cut down 7 | on reload times. 8 | 9 | [repl-no-load]: https://cabal.readthedocs.io/en/stable/cabal-commands.html#cmdoption-repl-no-load 10 | 11 | ## `--repl-no-load` in ghciwatch 12 | 13 | Ghciwatch supports `--repl-no-load` natively. Add `--repl-no-load` to the 14 | [`ghciwatch --command`](cli.md#--command) option and ghciwatch will start a 15 | GHCi session with no modules loaded. Then, edit a file and ghciwatch will load 16 | it (and its dependencies) into the REPL. (Note that because no modules are 17 | loaded initially, no compilation errors will show up until you start writing 18 | files.) 19 | 20 | ## `--repl-no-load` explained 21 | 22 | When you load a GHCi session with [`cabal repl`][cabal-repl], Cabal will 23 | interpret and load all the modules in the specified target before presenting a 24 | prompt: 25 | 26 | [cabal-repl]: https://cabal.readthedocs.io/en/stable/cabal-commands.html#cabal-repl 27 | 28 | ``` 29 | $ cabal repl test-dev 30 | Build profile: -w ghc-9.0.2 -O1 31 | In order, the following will be built (use -v for more details): 32 | - my-simple-package-0.1.0.0 (lib:test-dev) (first run) 33 | Configuring library 'test-dev' for my-simple-package-0.1.0.0.. 34 | Preprocessing library 'test-dev' for my-simple-package-0.1.0.0.. 35 | GHCi, version 9.0.2: https://www.haskell.org/ghc/ :? for help 36 | [1 of 3] Compiling MyLib ( src/MyLib.hs, interpreted ) 37 | [2 of 3] Compiling MyModule ( src/MyModule.hs, interpreted ) 38 | [3 of 3] Compiling TestMain ( test/TestMain.hs, interpreted ) 39 | Ok, three modules loaded. 40 | ghci> 41 | ``` 42 | 43 | For this toy project with three modules, that's not an issue, but it can start 44 | to add up with larger projects: 45 | 46 | ``` 47 | $ echo :quit | time cabal repl 48 | ... 49 | Ok, 9194 modules loaded. 50 | ghci> Leaving GHCi. 51 | ________________________________________________________ 52 | Executed in 161.07 secs 53 | ``` 54 | 55 | Fortunately, `cabal repl` includes a [`--repl-no-load`][repl-no-load] option 56 | which instructs Cabal to skip interpreting or loading _any_ modules until it's 57 | instructed to do so: 58 | 59 | ``` 60 | $ echo ":quit" | time cabal repl --repl-no-load 61 | ... 62 | ghci> Leaving GHCi. 63 | ________________________________________________________ 64 | Executed in 11.41 secs 65 | ``` 66 | 67 | Then, you can load modules into the empty GHCi session by `:add`ing them, and 68 | only the specified modules and their dependencies will be interpreted. If you 69 | only need to edit a small portion of a library's total modules, this can 70 | provide a significantly faster workflow than loading every module up-front. 71 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | //! `ghciwatch` is a `ghci`-based file watcher and recompiler for Haskell projects, leveraging 2 | //! Haskell's interpreted mode for faster reloads. 3 | //! 4 | //! `ghciwatch` watches your modules for changes and reloads them in a `ghci` session, displaying 5 | //! any errors. 6 | 7 | use std::time::Duration; 8 | 9 | use clap::CommandFactory; 10 | use clap::Parser; 11 | use ghciwatch::cli; 12 | use ghciwatch::run_ghci; 13 | use ghciwatch::run_tui; 14 | use ghciwatch::run_watcher; 15 | use ghciwatch::GhciOpts; 16 | use ghciwatch::ShutdownManager; 17 | use ghciwatch::TracingOpts; 18 | use ghciwatch::WatcherOpts; 19 | use tokio::sync::mpsc; 20 | 21 | #[tokio::main] 22 | async fn main() -> miette::Result<()> { 23 | miette::set_panic_hook(); 24 | let mut opts = cli::Opts::parse(); 25 | opts.init()?; 26 | let (maybe_tracing_reader, _tracing_guard) = TracingOpts::from_cli(&opts).install()?; 27 | 28 | #[cfg(feature = "clap-markdown")] 29 | if opts.generate_markdown_help { 30 | println!("{}", ghciwatch::clap_markdown::help_markdown::()); 31 | return Ok(()); 32 | } 33 | 34 | #[cfg(feature = "clap_mangen")] 35 | if let Some(out_dir) = opts.generate_man_pages { 36 | use miette::IntoDiagnostic; 37 | use miette::WrapErr; 38 | 39 | let command = cli::Opts::command(); 40 | clap_mangen::generate_to(command, out_dir) 41 | .into_diagnostic() 42 | .wrap_err("Failed to generate man pages")?; 43 | return Ok(()); 44 | } 45 | 46 | if let Some(shell) = opts.completions { 47 | let mut command = cli::Opts::command(); 48 | clap_complete::generate(shell, &mut command, "ghciwatch", &mut std::io::stdout()); 49 | return Ok(()); 50 | } 51 | 52 | std::env::set_var("IN_GHCIWATCH", "1"); 53 | 54 | let (ghci_sender, ghci_receiver) = mpsc::channel(32); 55 | 56 | let (ghci_opts, maybe_ghci_reader) = GhciOpts::from_cli(&opts)?; 57 | let watcher_opts = WatcherOpts::from_cli(&opts); 58 | 59 | let mut manager = ShutdownManager::with_timeout(Duration::from_secs(1)); 60 | 61 | if opts.tui { 62 | let tracing_reader = 63 | maybe_tracing_reader.expect("`tracing_reader` must be present if `tui` is given"); 64 | let ghci_reader = 65 | maybe_ghci_reader.expect("`tui_reader` must be present if `tui` is given"); 66 | manager 67 | .spawn("run_tui", |handle| { 68 | run_tui(handle, ghci_reader, tracing_reader) 69 | }) 70 | .await; 71 | } 72 | 73 | manager 74 | .spawn("run_ghci", |handle| { 75 | run_ghci(handle, ghci_opts, ghci_receiver) 76 | }) 77 | .await; 78 | manager 79 | .spawn("run_watcher", move |handle| { 80 | run_watcher(handle, ghci_sender, watcher_opts) 81 | }) 82 | .await; 83 | let ret = manager.wait_for_shutdown().await; 84 | tracing::debug!("main() finished"); 85 | ret 86 | } 87 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ghciwatch 2 | 3 | 👍🎉 First off, thanks for taking the time to contribute! 🎉👍 4 | 5 | ## Code of Conduct 6 | 7 | This project and everyone participating in it is governed by the [Contributor 8 | Covenant Code of Conduct][contributor-covenant]. 9 | 10 | [contributor-covenant]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/ 11 | 12 | ## Local Development 13 | 14 | **TL;DR:** Use `nix develop`, but you may be able to scrape by with `cargo`. 15 | 16 | A standard [Rust installation][rustup] with `cargo` is sufficient to build 17 | ghciwatch. If you're new to Rust, check out [Rust for 18 | Haskellers][rust-for-haskellers]. 19 | 20 | [rust-for-haskellers]: https://becca.ooo/blog/rust-for-haskellers/ 21 | 22 | To run tests, you'll need [Nix/Lix][lix] installed. Run `nix 23 | develop` to enter a [development shell][dev-env] with all the dependencies 24 | available and then use `cargo nextest run` to run the tests (including the 25 | integration tests) with [`cargo-nextest`][nextest]. (`cargo test` will work, 26 | too, but slower.) 27 | 28 | You can run the tests with coverage output with `cargo llvm-cov nextest`. 29 | it is [possible to display coverage][coverage-vscode] information in VSCode, with `cargo llvm-cov --lcov --output-path lcov.info`. 30 | 31 | [rustup]: https://rustup.rs/ 32 | [lix]: https://lix.systems/ 33 | [dev-env]: https://zero-to-nix.com/concepts/dev-env 34 | [nextest]: https://nexte.st/ 35 | [coverage-vscode]: https://github.com/taiki-e/cargo-llvm-cov?tab=readme-ov-file#display-coverage-in-vs-code 36 | 37 | ## Running the test suite without Nix 38 | 39 | Running the tests outside of Nix is generally not supported, but may be 40 | possible. You'll need a Haskell installation including GHC, `cabal`, and 41 | [`hpack`][hpack]. If you'd like to run the tests with (e.g.) GHC 9.6.5 and 9.8.2, run 42 | `GHC="9.6.5 9.8.2" cargo nextest run`. The test suite will expect to find 43 | executables named `ghc-9.6.5` and `ghc-9.8.2` on your `$PATH`. 44 | 45 | [hpack]: https://github.com/sol/hpack 46 | 47 | ## Why Rust? 48 | 49 | Rust makes it easy to ship static binaries. Rust also shares many features with 50 | Haskell: a [Hindley-Milner type system][hm] with inference, pattern matching, 51 | and immutability by default. Rust can also [interoperate with 52 | Haskell][hs-bindgen], so in the future we'll be able to ship `ghciwatch` as a 53 | Hackage package natively. Also, Rust's commitment to stability makes coping 54 | with multiple GHC versions and GHC upgrades easy. Finally, Rust is home to the 55 | excellent cross-platform and battle-tested [`notify`][notify] library, used to 56 | implement the [`watchexec`][watchexec] binary and `cargo-watch`, which solves a 57 | lot of the thorny problems of watching files for us. 58 | 59 | [hm]: https://en.wikipedia.org/wiki/Hindley%E2%80%93Milner_type_system 60 | [hs-bindgen]: https://engineering.iog.io/2023-01-26-hs-bindgen-introduction/ 61 | [watchexec]: https://github.com/watchexec/watchexec 62 | [notify]: https://docs.rs/notify/latest/notify/ 63 | -------------------------------------------------------------------------------- /src/ghci/parse/haskell_grammar.rs: -------------------------------------------------------------------------------- 1 | //! Parse elements of the Haskell grammar. 2 | //! 3 | //! See: ["Lexical Structure" in "The Haskell 2010 Language".][1] 4 | //! 5 | //! [1]: https://www.haskell.org/onlinereport/haskell2010/haskellch2.html 6 | 7 | use winnow::combinator::separated; 8 | use winnow::token::one_of; 9 | use winnow::token::take_while; 10 | use winnow::PResult; 11 | use winnow::Parser; 12 | 13 | /// A Haskell module name. 14 | /// 15 | /// See: `modid` in 16 | pub fn module_name<'i>(input: &mut &'i str) -> PResult<&'i str> { 17 | // Surely there's a better way to get type inference to work here? 18 | separated::<_, _, (), _, _, _, _>(1.., constructor_name, ".") 19 | .recognize() 20 | .parse_next(input) 21 | } 22 | 23 | /// A Haskell constructor name. 24 | /// 25 | /// See: `conid` in 26 | fn constructor_name<'i>(input: &mut &'i str) -> PResult<&'i str> { 27 | // TODO: Support Unicode letters. 28 | ( 29 | one_of('A'..='Z'), 30 | take_while(0.., ('A'..='Z', 'a'..='z', '0'..='9', '\'', '_')), 31 | ) 32 | .recognize() 33 | .parse_next(input) 34 | } 35 | 36 | #[cfg(test)] 37 | mod tests { 38 | use pretty_assertions::assert_eq; 39 | 40 | use super::*; 41 | 42 | #[test] 43 | fn test_parse_module_name() { 44 | assert_eq!(module_name.parse("Foo").unwrap(), "Foo"); 45 | assert_eq!(module_name.parse("Foo.Bar").unwrap(), "Foo.Bar"); 46 | assert_eq!(module_name.parse("Foo.Bar'").unwrap(), "Foo.Bar'"); 47 | assert_eq!(module_name.parse("Foo.Bar1").unwrap(), "Foo.Bar1"); 48 | 49 | assert_eq!(module_name.parse("A").unwrap(), "A"); 50 | assert_eq!(module_name.parse("A.B.C").unwrap(), "A.B.C"); 51 | assert_eq!(module_name.parse("Foo_Bar").unwrap(), "Foo_Bar"); 52 | assert_eq!(module_name.parse("Dog_").unwrap(), "Dog_"); 53 | assert_eq!(module_name.parse("D'_").unwrap(), "D'_"); 54 | 55 | // Negative cases. 56 | // Not capitalized. 57 | assert!(module_name.parse("foo.bar").is_err()); 58 | // Forbidden characters. 59 | assert!(module_name.parse("'foo").is_err()); 60 | assert!(module_name.parse("1foo").is_err()); 61 | assert!(module_name.parse("Foo::Bar").is_err()); 62 | assert!(module_name.parse("Foo.Bar:").is_err()); 63 | // Multiple dots. 64 | assert!(module_name.parse("Foo..Bar").is_err()); 65 | assert!(module_name.parse("Foo.Bar.").is_err()); 66 | assert!(module_name.parse(".Foo.Bar").is_err()); 67 | assert!(module_name.parse(" Foo.Bar").is_err()); 68 | // Whitespace. 69 | assert!(module_name.parse("Foo.Bar ").is_err()); 70 | assert!(module_name.parse("Foo. Bar").is_err()); 71 | assert!(module_name.parse("Foo .Bar").is_err()); 72 | assert!(module_name.parse("Foo.Bar Baz.Boz").is_err()); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/ghci/module_set.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Borrow; 2 | use std::borrow::Cow; 3 | use std::cmp::Eq; 4 | use std::collections::HashSet; 5 | use std::hash::Hash; 6 | 7 | use crate::normal_path::NormalPath; 8 | 9 | use super::loaded_module::LoadedModule; 10 | 11 | /// A collection of source paths, retaining information about loaded modules in a `ghci` 12 | /// session. 13 | #[derive(Debug, Clone, Default, PartialEq, Eq)] 14 | pub struct ModuleSet { 15 | modules: HashSet, 16 | } 17 | 18 | impl ModuleSet { 19 | /// Iterate over the modules in this set. 20 | pub fn iter(&self) -> std::collections::hash_set::Iter<'_, LoadedModule> { 21 | self.modules.iter() 22 | } 23 | 24 | /// Iterate over the modules in this set. 25 | #[cfg(test)] 26 | pub fn into_iter(self) -> std::collections::hash_set::IntoIter { 27 | self.modules.into_iter() 28 | } 29 | 30 | /// Get the number of modules in this set. 31 | pub fn len(&self) -> usize { 32 | self.modules.len() 33 | } 34 | 35 | /// Determine if a module with the given source path is contained in this module set. 36 | pub fn contains_source_path

(&self, path: &P) -> bool 37 | where 38 | LoadedModule: Borrow

, 39 | P: Hash + Eq + ?Sized, 40 | { 41 | self.modules.contains(path) 42 | } 43 | 44 | /// Add a module to this set. 45 | /// 46 | /// Returns whether the module was newly inserted. 47 | pub fn insert_module(&mut self, module: LoadedModule) -> bool { 48 | self.modules.insert(module) 49 | } 50 | 51 | /// Remove a source path from this module set. 52 | /// 53 | /// Returns whether the path was present in the set. 54 | pub fn remove_source_path

(&mut self, path: &P) -> bool 55 | where 56 | LoadedModule: Borrow

, 57 | P: Hash + Eq + ?Sized, 58 | { 59 | self.modules.remove(path) 60 | } 61 | 62 | /// Get a module in this set. 63 | pub fn get_module

(&self, path: &P) -> Option<&LoadedModule> 64 | where 65 | LoadedModule: Borrow

, 66 | P: Hash + Eq + ?Sized, 67 | { 68 | self.modules.get(path) 69 | } 70 | 71 | /// Get the import name for a module. 72 | /// 73 | /// The path parameter should be relative to the GHCi session's working directory. 74 | pub fn get_import_name(&self, path: &NormalPath) -> Cow<'_, LoadedModule> { 75 | match self.get_module(path) { 76 | Some(module) => Cow::Borrowed(module), 77 | None => Cow::Owned(LoadedModule::new(path.clone())), 78 | } 79 | } 80 | } 81 | 82 | impl FromIterator for ModuleSet { 83 | fn from_iter>(iter: T) -> Self { 84 | Self { 85 | modules: iter.into_iter().collect(), 86 | } 87 | } 88 | } 89 | 90 | impl Extend for ModuleSet { 91 | fn extend>(&mut self, iter: T) { 92 | self.modules.extend(iter) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/ghci/parse/ghc_message/compiling.rs: -------------------------------------------------------------------------------- 1 | use module_and_files::CompilingModule; 2 | use winnow::ascii::digit1; 3 | use winnow::ascii::space0; 4 | use winnow::PResult; 5 | use winnow::Parser; 6 | 7 | use crate::ghci::parse::lines::rest_of_line; 8 | use crate::ghci::parse::module_and_files; 9 | 10 | /// Parse a `[1 of 3] Compiling Foo ( Foo.hs, Foo.o, interpreted )` message. 11 | pub fn compiling(input: &mut &str) -> PResult { 12 | let _ = "[".parse_next(input)?; 13 | let _ = space0.parse_next(input)?; 14 | let _ = digit1.parse_next(input)?; 15 | let _ = " of ".parse_next(input)?; 16 | let _ = digit1.parse_next(input)?; 17 | let _ = "]".parse_next(input)?; 18 | let _ = " Compiling ".parse_next(input)?; 19 | let module = module_and_files.parse_next(input)?; 20 | let _ = rest_of_line.parse_next(input)?; 21 | 22 | Ok(module) 23 | } 24 | 25 | #[cfg(test)] 26 | mod tests { 27 | use super::*; 28 | use module_and_files::CompilingModule; 29 | 30 | use indoc::indoc; 31 | use pretty_assertions::assert_eq; 32 | 33 | #[test] 34 | fn test_parse_compiling_message() { 35 | assert_eq!( 36 | compiling 37 | .parse("[1 of 3] Compiling Foo ( Foo.hs, Foo.o, interpreted )\n") 38 | .unwrap(), 39 | CompilingModule { 40 | name: "Foo".into(), 41 | path: "Foo.hs".into() 42 | } 43 | ); 44 | 45 | assert_eq!( 46 | compiling 47 | .parse("[ 1 of 6508] \ 48 | Compiling A.DoggyPrelude.Puppy \ 49 | ( src/A/DoggyPrelude/Puppy.hs, \ 50 | /Users/wiggles/doggy-web-backend6/dist-newstyle/build/aarch64-osx/ghc-9.6.2/doggy-web-backend-0/l/test-dev/noopt/build/test-dev/A/DoggyPrelude/Puppy.dyn_o \ 51 | ) [Doggy.Lint package changed]\n") 52 | .unwrap(), 53 | CompilingModule { 54 | name: "A.DoggyPrelude.Puppy".into(), 55 | path: "src/A/DoggyPrelude/Puppy.hs".into() 56 | } 57 | ); 58 | 59 | assert_eq!( 60 | compiling 61 | .parse("[1 of 4] Compiling MyLib ( src/MyLib.hs )\n") 62 | .unwrap(), 63 | CompilingModule { 64 | name: "MyLib".into(), 65 | path: "src/MyLib.hs".into() 66 | } 67 | ); 68 | 69 | // Shouldn't parse multiple lines. 70 | assert!(compiling 71 | .parse(indoc!( 72 | " 73 | [1 of 4] Compiling MyLib ( src/MyLib.hs ) 74 | [1 of 4] Compiling MyLib ( src/MyLib.hs, interpreted ) 75 | " 76 | )) 77 | .is_err()); 78 | assert!(compiling 79 | .parse(indoc!( 80 | " 81 | [1 of 4] Compiling MyLib ( src/MyLib.hs ) 82 | [1 of 4] Compiling MyLib ( src/MyLib.hs ) 83 | " 84 | )) 85 | .is_err()); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghciwatch 2 | 3 | 4 | Packaging status 5 | 6 |
7 | 8 | Packaging status 9 | 10 |
11 | 12 | User manual 13 | 14 | 15 | Ghciwatch loads a [GHCi][ghci] session for a Haskell project and reloads it 16 | when source files change. 17 | 18 | [ghci]: https://downloads.haskell.org/ghc/latest/docs/users_guide/ghci.html 19 | 20 | ## Features 21 | 22 | - GHCi output is displayed to the user as soon as it's printed. 23 | - Ghciwatch can handle new modules, removed modules, or moved modules without a 24 | hitch 25 | - A variety of [lifecycle 26 | hooks](https://mercurytechnologies.github.io/ghciwatch/lifecycle-hooks.html) 27 | let you run Haskell code or shell commands on a variety of events. 28 | - Run a test suite with [`--test-ghci 29 | TestMain.testMain`](https://mercurytechnologies.github.io/ghciwatch/cli.html#--test-ghci). 30 | - Refresh your `.cabal` files with [`hpack`][hpack] before GHCi starts using 31 | [`--before-startup-shell 32 | hpack`](https://mercurytechnologies.github.io/ghciwatch/cli.html#--before-startup-shell). 33 | - Format your code asynchronously using [`--before-reload-shell 34 | async:fourmolu`](https://mercurytechnologies.github.io/ghciwatch/cli.html#--before-reload-shell). 35 | - [Custom 36 | globs](https://mercurytechnologies.github.io/ghciwatch/cli.html#--reload-glob) 37 | can be supplied to reload or restart the GHCi session when non-Haskell files 38 | (like templates or database schema definitions) change. 39 | - Ghciwatch can [clear the screen between reloads](https://mercurytechnologies.github.io/ghciwatch/cli.html#--clear). 40 | - Compilation errors can be written to a file with 41 | [`--error-file`](https://mercurytechnologies.github.io/ghciwatch/cli.html#--error-file), 42 | for compatibility with [ghcid's][ghcid] `--outputfile` option. 43 | - Comments starting with `-- $>` [can be 44 | evaluated](https://mercurytechnologies.github.io/ghciwatch/comment-evaluation.html) 45 | in GHCi. 46 | - Eval comments have access to the top-level bindings of the module they're 47 | defined in, including unexported bindings. 48 | - Multi-line eval comments are supported with `{- $> ... <$ -}`. 49 | 50 | [ghcid]: https://github.com/ndmitchell/ghcid 51 | [hpack]: https://github.com/sol/hpack 52 | 53 | ## Demo 54 | 55 | Check out a quick demo to see how ghciwatch feels in practice: 56 | 57 | 58 | 59 | ## Learn More 60 | 61 | [Read the manual here](https://mercurytechnologies.github.io/ghciwatch/). 62 | 63 | ## Developing ghciwatch 64 | 65 | See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for information on hacking 66 | ghciwatch. 67 | -------------------------------------------------------------------------------- /test-harness-macro/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! `#[test]` attribute macro for `ghciwatch` integration tests. 2 | 3 | #![deny(missing_docs)] 4 | #![deny(rustdoc::broken_intra_doc_links)] 5 | 6 | use proc_macro::TokenStream; 7 | 8 | use quote::quote; 9 | use quote::ToTokens; 10 | use syn::parse; 11 | use syn::parse::Parse; 12 | use syn::parse::ParseStream; 13 | use syn::Attribute; 14 | use syn::Block; 15 | use syn::Ident; 16 | use syn::ItemFn; 17 | 18 | /// Runs a test asynchronously in the `tokio` current-thread runtime with `tracing` enabled. 19 | /// 20 | /// One test is generated for each GHC version listed in the `$GHC_VERSIONS` environment variable 21 | /// at compile-time. 22 | #[proc_macro_attribute] 23 | pub fn test(_attr: TokenStream, item: TokenStream) -> TokenStream { 24 | // Parse annotated function 25 | let mut function: ItemFn = parse(item).expect("Could not parse item as function"); 26 | 27 | // Add attributes to run the test in the `tokio` current-thread runtime and enable tracing. 28 | function.attrs.extend( 29 | parse::( 30 | quote! { 31 | #[tokio::test] 32 | #[tracing_test::traced_test] 33 | #[allow(non_snake_case)] 34 | } 35 | .into(), 36 | ) 37 | .expect("Could not parse quoted attributes") 38 | .0, 39 | ); 40 | 41 | let ghc_versions = match option_env!("GHC_VERSIONS") { 42 | None => { 43 | panic!("`$GHC_VERSIONS` should be set to a list of GHC versions to run tests under, separated by spaces, like `9.0.2 9.2.8 9.4.6 9.6.2`."); 44 | } 45 | Some(versions) => versions.split_ascii_whitespace().collect::>(), 46 | }; 47 | 48 | // Generate functions for each GHC version we want to test. 49 | let mut ret = TokenStream::new(); 50 | for ghc_version in ghc_versions { 51 | ret.extend::( 52 | make_test_fn(function.clone(), ghc_version) 53 | .to_token_stream() 54 | .into(), 55 | ); 56 | } 57 | ret 58 | } 59 | 60 | struct Attributes(Vec); 61 | 62 | impl Parse for Attributes { 63 | fn parse(input: ParseStream) -> syn::Result { 64 | Ok(Self(input.call(Attribute::parse_outer)?)) 65 | } 66 | } 67 | 68 | fn make_test_fn(mut function: ItemFn, ghc_version: &str) -> ItemFn { 69 | let ghc_version_ident = ghc_version.replace('.', ""); 70 | let stmts = function.block.stmts; 71 | let test_name_base = function.sig.ident.to_string(); 72 | let test_name = format!("{test_name_base}_{ghc_version_ident}"); 73 | function.sig.ident = Ident::new(&test_name, function.sig.ident.span()); 74 | 75 | // Wrap the test code in startup/cleanup code. 76 | let new_body = parse::( 77 | quote! { 78 | { 79 | ::test_harness::internal::wrap_test( 80 | async { 81 | #(#stmts);* 82 | }, 83 | #ghc_version, 84 | #test_name, 85 | env!("CARGO_TARGET_TMPDIR"), 86 | ).await; 87 | } 88 | } 89 | .into(), 90 | ) 91 | .expect("Could not parse function body"); 92 | 93 | // Replace function body 94 | *function.block = new_body; 95 | 96 | function 97 | } 98 | -------------------------------------------------------------------------------- /src/ghci/writer.rs: -------------------------------------------------------------------------------- 1 | use async_dup::Arc; 2 | use async_dup::Mutex; 3 | use std::fmt::Debug; 4 | use std::io; 5 | use std::pin::Pin; 6 | use std::task::Context; 7 | use std::task::Poll; 8 | use tokio::io::AsyncWrite; 9 | use tokio::io::DuplexStream; 10 | use tokio::io::Sink; 11 | use tokio::io::Stderr; 12 | use tokio::io::Stdout; 13 | use tokio_util::compat::Compat; 14 | use tokio_util::compat::FuturesAsyncWriteCompatExt; 15 | use tokio_util::compat::TokioAsyncWriteCompatExt; 16 | 17 | /// A dynamically reconfigurable sink for `ghci` process output. Built for use in `GhciOpts`, but 18 | /// usable as a general purpose clonable [`AsyncWrite`]r. 19 | #[derive(Debug)] 20 | pub struct GhciWriter(Kind); 21 | 22 | #[derive(Debug)] 23 | enum Kind { 24 | Stdout(Stdout), 25 | Stderr(Stderr), 26 | DuplexStream(Compat>>>), 27 | Sink(Sink), 28 | } 29 | 30 | impl GhciWriter { 31 | /// Write to `stdout`. 32 | pub fn stdout() -> Self { 33 | Self(Kind::Stdout(tokio::io::stdout())) 34 | } 35 | 36 | /// Write to `stderr`. 37 | pub fn stderr() -> Self { 38 | Self(Kind::Stderr(tokio::io::stderr())) 39 | } 40 | 41 | /// Write to an in-memory buffer. 42 | pub fn duplex_stream(duplex_stream: DuplexStream) -> Self { 43 | Self(Kind::DuplexStream( 44 | Arc::new(Mutex::new(duplex_stream.compat_write())).compat_write(), 45 | )) 46 | } 47 | 48 | /// Write to the void. 49 | pub fn sink() -> Self { 50 | Self(Kind::Sink(tokio::io::sink())) 51 | } 52 | } 53 | 54 | impl AsyncWrite for GhciWriter { 55 | fn poll_write( 56 | self: Pin<&mut Self>, 57 | cx: &mut Context<'_>, 58 | buf: &[u8], 59 | ) -> Poll> { 60 | match Pin::into_inner(self).0 { 61 | Kind::Stdout(ref mut x) => Pin::new(x).poll_write(cx, buf), 62 | Kind::Stderr(ref mut x) => Pin::new(x).poll_write(cx, buf), 63 | Kind::DuplexStream(ref mut x) => Pin::new(x).poll_write(cx, buf), 64 | Kind::Sink(ref mut x) => Pin::new(x).poll_write(cx, buf), 65 | } 66 | } 67 | 68 | fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 69 | match Pin::into_inner(self).0 { 70 | Kind::Stdout(ref mut x) => Pin::new(x).poll_flush(cx), 71 | Kind::Stderr(ref mut x) => Pin::new(x).poll_flush(cx), 72 | Kind::DuplexStream(ref mut x) => Pin::new(x).poll_flush(cx), 73 | Kind::Sink(ref mut x) => Pin::new(x).poll_flush(cx), 74 | } 75 | } 76 | 77 | fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 78 | match Pin::into_inner(self).0 { 79 | Kind::Stdout(ref mut x) => Pin::new(x).poll_shutdown(cx), 80 | Kind::Stderr(ref mut x) => Pin::new(x).poll_shutdown(cx), 81 | Kind::DuplexStream(ref mut x) => Pin::new(x).poll_shutdown(cx), 82 | Kind::Sink(ref mut x) => Pin::new(x).poll_shutdown(cx), 83 | } 84 | } 85 | } 86 | 87 | impl Clone for GhciWriter { 88 | fn clone(&self) -> Self { 89 | match &self.0 { 90 | Kind::Stdout(_) => Self::stdout(), 91 | Kind::Stderr(_) => Self::stderr(), 92 | Kind::DuplexStream(x) => Self(Kind::DuplexStream(x.clone())), 93 | Kind::Sink(_) => Self::sink(), 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /test-harness/src/checkpoint.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | use std::fmt::Display; 3 | use std::ops::Range; 4 | use std::ops::RangeFrom; 5 | use std::ops::RangeFull; 6 | use std::ops::RangeInclusive; 7 | use std::ops::RangeTo; 8 | use std::ops::RangeToInclusive; 9 | use std::slice::SliceIndex; 10 | 11 | use crate::Event; 12 | 13 | /// A checkpoint in a `ghciwatch` run. 14 | /// 15 | /// [`crate::GhciWatch`] provides methods for asserting that events are logged, or waiting for 16 | /// events to be logged in the future. 17 | /// 18 | /// To avoid searching thousands of log events for each assertion, and to provide greater 19 | /// granularity for assertions, you can additionally assert that events are logged between 20 | /// particular checkpoints. 21 | /// 22 | /// Checkpoints can be constructed with [`crate::GhciWatch::first_checkpoint`], 23 | /// [`crate::GhciWatch::current_checkpoint`], and [`crate::GhciWatch::checkpoint`]. 24 | #[derive(Debug, Clone, Copy)] 25 | pub struct Checkpoint(pub(crate) usize); 26 | 27 | impl Checkpoint { 28 | /// Get the underlying `usize` from this checkpoint. 29 | pub fn into_inner(self) -> usize { 30 | self.0 31 | } 32 | } 33 | 34 | impl Display for Checkpoint { 35 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 36 | write!(f, "{}", self.0) 37 | } 38 | } 39 | 40 | /// A type that can be used to index a set of checkpoints. 41 | pub trait CheckpointIndex: Clone + Debug { 42 | /// The resulting index type. 43 | type Index: SliceIndex<[Vec], Output = [Vec]> + Debug + Clone; 44 | 45 | /// Convert the value into an index. 46 | fn as_index(&self) -> Self::Index; 47 | } 48 | 49 | impl CheckpointIndex for &C 50 | where 51 | C: Clone + Debug + CheckpointIndex, 52 | { 53 | type Index = ::Index; 54 | 55 | fn as_index(&self) -> Self::Index { 56 | ::as_index(self) 57 | } 58 | } 59 | 60 | impl CheckpointIndex for Checkpoint { 61 | type Index = RangeInclusive; 62 | 63 | fn as_index(&self) -> Self::Index { 64 | let index = self.into_inner(); 65 | index..=index 66 | } 67 | } 68 | 69 | impl CheckpointIndex for Range { 70 | type Index = Range; 71 | 72 | fn as_index(&self) -> Self::Index { 73 | self.start.into_inner()..self.end.into_inner() 74 | } 75 | } 76 | 77 | impl CheckpointIndex for RangeFrom { 78 | type Index = RangeFrom; 79 | 80 | fn as_index(&self) -> Self::Index { 81 | self.start.into_inner().. 82 | } 83 | } 84 | 85 | impl CheckpointIndex for RangeFull { 86 | type Index = RangeFull; 87 | 88 | fn as_index(&self) -> Self::Index { 89 | *self 90 | } 91 | } 92 | 93 | impl CheckpointIndex for RangeInclusive { 94 | type Index = RangeInclusive; 95 | 96 | fn as_index(&self) -> Self::Index { 97 | self.start().into_inner()..=self.end().into_inner() 98 | } 99 | } 100 | 101 | impl CheckpointIndex for RangeTo { 102 | type Index = RangeTo; 103 | 104 | fn as_index(&self) -> Self::Index { 105 | ..self.end.into_inner() 106 | } 107 | } 108 | 109 | impl CheckpointIndex for RangeToInclusive { 110 | type Index = RangeToInclusive; 111 | 112 | fn as_index(&self) -> Self::Index { 113 | ..=self.end.into_inner() 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "advisory-db": { 4 | "flake": false, 5 | "locked": { 6 | "lastModified": 1757608478, 7 | "narHash": "sha256-K645EKvzX/osYF1LmLjxO3ekhFpSXksFl+32i3ikSwE=", 8 | "owner": "rustsec", 9 | "repo": "advisory-db", 10 | "rev": "9097f1eb5e5130368a800e6dfbf9819d4e62b785", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "owner": "rustsec", 15 | "repo": "advisory-db", 16 | "type": "github" 17 | } 18 | }, 19 | "crane": { 20 | "locked": { 21 | "lastModified": 1757183466, 22 | "narHash": "sha256-kTdCCMuRE+/HNHES5JYsbRHmgtr+l9mOtf5dpcMppVc=", 23 | "owner": "ipetkov", 24 | "repo": "crane", 25 | "rev": "d599ae4847e7f87603e7082d73ca673aa93c916d", 26 | "type": "github" 27 | }, 28 | "original": { 29 | "owner": "ipetkov", 30 | "repo": "crane", 31 | "type": "github" 32 | } 33 | }, 34 | "flake-compat": { 35 | "flake": false, 36 | "locked": { 37 | "lastModified": 1747046372, 38 | "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", 39 | "owner": "edolstra", 40 | "repo": "flake-compat", 41 | "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", 42 | "type": "github" 43 | }, 44 | "original": { 45 | "owner": "edolstra", 46 | "repo": "flake-compat", 47 | "type": "github" 48 | } 49 | }, 50 | "nixpkgs": { 51 | "locked": { 52 | "lastModified": 1757487488, 53 | "narHash": "sha256-zwE/e7CuPJUWKdvvTCB7iunV4E/+G0lKfv4kk/5Izdg=", 54 | "owner": "NixOS", 55 | "repo": "nixpkgs", 56 | "rev": "ab0f3607a6c7486ea22229b92ed2d355f1482ee0", 57 | "type": "github" 58 | }, 59 | "original": { 60 | "owner": "NixOS", 61 | "ref": "nixos-unstable", 62 | "repo": "nixpkgs", 63 | "type": "github" 64 | } 65 | }, 66 | "root": { 67 | "inputs": { 68 | "advisory-db": "advisory-db", 69 | "crane": "crane", 70 | "flake-compat": "flake-compat", 71 | "nixpkgs": "nixpkgs", 72 | "rust-overlay": "rust-overlay", 73 | "systems": "systems" 74 | } 75 | }, 76 | "rust-overlay": { 77 | "inputs": { 78 | "nixpkgs": [ 79 | "nixpkgs" 80 | ] 81 | }, 82 | "locked": { 83 | "lastModified": 1757558036, 84 | "narHash": "sha256-DyZaeaHy8iibckZ63XOqYJtEHc3kmVy8JrBIBV/GQHI=", 85 | "owner": "oxalica", 86 | "repo": "rust-overlay", 87 | "rev": "b8adf899786b7b77b8c3636a9b753e3622f00db0", 88 | "type": "github" 89 | }, 90 | "original": { 91 | "owner": "oxalica", 92 | "repo": "rust-overlay", 93 | "type": "github" 94 | } 95 | }, 96 | "systems": { 97 | "locked": { 98 | "lastModified": 1681028828, 99 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 100 | "owner": "nix-systems", 101 | "repo": "default", 102 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 103 | "type": "github" 104 | }, 105 | "original": { 106 | "owner": "nix-systems", 107 | "repo": "default", 108 | "type": "github" 109 | } 110 | } 111 | }, 112 | "root": "root", 113 | "version": 7 114 | } 115 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "test-harness", 4 | "test-harness-macro", 5 | ] 6 | resolver = "2" 7 | 8 | # See: https://github.com/crate-ci/cargo-release/blob/master/docs/reference.md 9 | [workspace.metadata.release] 10 | # Set the commit message. 11 | pre-release-commit-message = "Release {{crate_name}} version {{version}}" 12 | consolidate-commits = false # One commit per crate. 13 | tag = false # Don't tag commits. 14 | push = false # Don't do `git push`. 15 | publish = false # Don't do `cargo publish`. 16 | 17 | # Define the root package: https://doc.rust-lang.org/cargo/reference/workspaces.html#root-package 18 | [package] 19 | name = "ghciwatch" 20 | version = "1.1.5" 21 | edition = "2021" 22 | authors = [ 23 | "Rebecca Turner " 24 | ] 25 | description = "Ghciwatch loads a GHCi session for a Haskell project and reloads it when source files change." 26 | readme = "README.md" 27 | homepage = "https://github.com/MercuryTechnologies/ghciwatch" 28 | repository = "https://github.com/MercuryTechnologies/ghciwatch" 29 | license = "MIT" 30 | keywords = ["haskell", "ghci", "compile", "watch", "notify"] 31 | categories = ["command-line-utilities", "development-tools"] 32 | 33 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 34 | 35 | [features] 36 | clap-markdown = [] 37 | 38 | [dependencies] 39 | aho-corasick = "1.0.2" 40 | ansi-to-tui = "4.0.1" 41 | async-dup = "1.2.4" 42 | backoff = { version = "0.4.0", default-features = false } 43 | camino = "1.1.4" 44 | # Clap 4.4 is the last version supporting Rust 1.72. 45 | clap = { version = "~4.4", features = ["derive", "wrap_help", "env", "string"] } 46 | clap_complete = "~4.4" 47 | clap_mangen = { version = "=0.2.19", optional = true } 48 | clearscreen = "2.0.1" 49 | command-group = { version = "2.1.0", features = ["tokio", "with-tokio"] } 50 | crossterm = { version = "0.27.0", features = ["event-stream"] } 51 | enum-iterator = "1.4.1" 52 | humantime = "2.3.0" 53 | ignore = "0.4.20" 54 | indoc = "1.0.6" 55 | itertools = "0.11.0" 56 | line-span = "0.1.5" 57 | miette = { version = "5.9.0", features = ["fancy"] } 58 | nix = { version = "0.26.2", default-features = false, features = ["process", "signal"] } 59 | notify-debouncer-full = "0.3.1" 60 | once_cell = "1.18.0" 61 | owo-colors = { version = "3.5.0", features = ["supports-colors"] } 62 | path-absolutize = "3.1.1" 63 | pathdiff = { version = "0.2.1", features = ["camino"] } 64 | ratatui = "=0.26.1" # 0.26.2 needs Rust 1.72. 65 | regex = { version = "1.9.3", default-features = false, features = ["perf", "std"] } 66 | saturating = "0.1.0" # Needed until we have Rust 1.74. 67 | shell-words = "1.1.0" 68 | strip-ansi-escapes = "0.2.0" 69 | supports-color = "2.1.0" 70 | tap = "1.0.1" 71 | textwrap = { version = "0.16.0", features = ["terminal_size"] } 72 | tokio = { version = "1.28.2", features = ["full", "tracing"] } 73 | tokio-stream = { version = "0.1.14", default-features = false } 74 | tokio-util = { version = "0.7.10", features = ["compat", "io-util"] } 75 | tracing = "0.1.37" 76 | tracing-appender = "0.2.3" 77 | tracing-human-layer = "0.1.3" 78 | tracing-subscriber = { version = "0.3.17", features = ["env-filter", "time", "json", "registry"] } 79 | unindent = "0.2.3" 80 | winnow = "0.5.15" 81 | 82 | [dev-dependencies] 83 | test-harness = { path = "test-harness" } 84 | expect-test = "1.4.0" 85 | pretty_assertions = "1.2.1" 86 | tracing-test = { version = "0.2", features = ["no-env-filter"] } 87 | cargo-llvm-cov = "0.6.9" 88 | 89 | [lib] 90 | path = "src/lib.rs" 91 | 92 | [profile.release] 93 | codegen-units = 1 94 | lto = true 95 | -------------------------------------------------------------------------------- /test-harness/src/ghc_version.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | use std::str::FromStr; 3 | use std::sync::OnceLock; 4 | 5 | use miette::miette; 6 | use regex::Regex; 7 | 8 | /// A GHC version, including the patch level. 9 | pub struct FullGhcVersion { 10 | /// The major version. 11 | pub major: GhcVersion, 12 | /// The full version string. 13 | pub full: String, 14 | } 15 | 16 | impl FullGhcVersion { 17 | /// Get the GHC version for the current test. 18 | pub fn current() -> miette::Result { 19 | let full = crate::internal::get_ghc_version()?; 20 | let major = full.parse()?; 21 | Ok(Self { full, major }) 22 | } 23 | } 24 | 25 | impl Display for FullGhcVersion { 26 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 27 | write!(f, "{}", self.full) 28 | } 29 | } 30 | 31 | /// A major version of GHC. 32 | /// 33 | /// Variants of this enum will correspond to `ghcVersions` in `../../flake.nix`. 34 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] 35 | pub enum GhcVersion { 36 | /// GHC 9.4 37 | Ghc94, 38 | /// GHC 9.6 39 | Ghc96, 40 | /// GHC 9.8 41 | Ghc98, 42 | /// GHC 9.10 43 | Ghc910, 44 | /// GHC 9.12 45 | Ghc912, 46 | } 47 | 48 | fn ghc_version_re() -> &'static Regex { 49 | static RE: OnceLock = OnceLock::new(); 50 | RE.get_or_init(|| Regex::new(r"^([0-9]+)\.([0-9]+)\.([0-9]+)$").unwrap()) 51 | } 52 | 53 | impl FromStr for GhcVersion { 54 | type Err = miette::Error; 55 | 56 | fn from_str(s: &str) -> Result { 57 | let captures = ghc_version_re().captures(s).ok_or_else(|| { 58 | miette!("Failed to parse GHC version. Expected a string like \"9.6.2\", got {s:?}") 59 | })?; 60 | 61 | let (_full, [major, minor, _patch]) = captures.extract(); 62 | 63 | match (major, minor) { 64 | ("9", "4") => Ok(Self::Ghc94), 65 | ("9", "6") => Ok(Self::Ghc96), 66 | ("9", "8") => Ok(Self::Ghc98), 67 | ("9", "10") => Ok(Self::Ghc910), 68 | ("9", "12") => Ok(Self::Ghc912), 69 | (_, _) => Err(miette!( 70 | "Only the following GHC versions are supported:\n\ 71 | - 9.4\n\ 72 | - 9.6\n\ 73 | - 9.8\n\ 74 | - 9.10\n\ 75 | - 9.12" 76 | )), 77 | } 78 | } 79 | } 80 | 81 | #[cfg(test)] 82 | mod tests { 83 | use super::*; 84 | 85 | #[test] 86 | fn test_parse_ghc_version() { 87 | assert_eq!("9.4.8".parse::().unwrap(), GhcVersion::Ghc94); 88 | assert_eq!("9.6.1".parse::().unwrap(), GhcVersion::Ghc96); 89 | assert_eq!("9.10.1".parse::().unwrap(), GhcVersion::Ghc910); 90 | assert_eq!("9.12.1".parse::().unwrap(), GhcVersion::Ghc910); 91 | 92 | "9.6.1rc1" 93 | .parse::() 94 | .expect_err("Extra information at the end"); 95 | "9.6.1-pre" 96 | .parse::() 97 | .expect_err("Extra information at the end"); 98 | "9.6.1.2" 99 | .parse::() 100 | .expect_err("Extra version component"); 101 | "9.6" 102 | .parse::() 103 | .expect_err("Missing patch version component"); 104 | "9".parse::() 105 | .expect_err("Missing patch and minor version components"); 106 | "a.b.c" 107 | .parse::() 108 | .expect_err("Non-numeric components"); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /test-harness/src/matcher/option_matcher.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::IntoMatcher; 4 | use crate::Matcher; 5 | 6 | use super::NeverMatcher; 7 | 8 | /// A matcher which may or may not contain a matcher. 9 | /// 10 | /// If it does not contain a matcher, it never matches. 11 | #[derive(Clone)] 12 | pub struct OptionMatcher(Option); 13 | 14 | impl OptionMatcher { 15 | /// Construct an empty matcher. 16 | pub fn none() -> Self { 17 | Self(None) 18 | } 19 | } 20 | 21 | impl OptionMatcher { 22 | /// Construct a matcher from the given inner matcher. 23 | pub fn some(inner: M) -> Self { 24 | Self(Some(inner)) 25 | } 26 | } 27 | 28 | impl Display for OptionMatcher { 29 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 30 | match &self.0 { 31 | Some(matcher) => write!(f, "{matcher}"), 32 | None => write!(f, "(nothing)"), 33 | } 34 | } 35 | } 36 | 37 | impl Matcher for OptionMatcher { 38 | fn matches(&mut self, event: &crate::Event) -> miette::Result { 39 | match &mut self.0 { 40 | Some(ref mut matcher) => matcher.matches(event), 41 | None => Ok(false), 42 | } 43 | } 44 | } 45 | 46 | impl IntoMatcher for Option { 47 | type Matcher = OptionMatcher; 48 | 49 | fn into_matcher(self) -> miette::Result { 50 | Ok(OptionMatcher(self)) 51 | } 52 | } 53 | 54 | #[cfg(test)] 55 | mod tests { 56 | use tracing::Level; 57 | 58 | use crate::tracing_json::Span; 59 | use crate::Event; 60 | use crate::IntoMatcher; 61 | 62 | use super::*; 63 | 64 | #[test] 65 | fn test_option_matcher_some() { 66 | let mut matcher = OptionMatcher::some("puppy".into_matcher().unwrap()); 67 | let event = Event { 68 | message: "puppy".to_owned(), 69 | timestamp: "2023-08-25T22:14:30.067641Z".to_owned(), 70 | level: Level::INFO, 71 | fields: Default::default(), 72 | target: "ghciwatch::ghci".to_owned(), 73 | span: Some(Span { 74 | name: "ghci".to_owned(), 75 | fields: Default::default(), 76 | }), 77 | spans: vec![Span { 78 | name: "ghci".to_owned(), 79 | fields: Default::default(), 80 | }], 81 | }; 82 | 83 | assert!(matcher.matches(&event).unwrap()); 84 | assert!(!matcher 85 | .matches(&Event { 86 | message: "doggy".to_owned(), 87 | ..event 88 | }) 89 | .unwrap()); 90 | } 91 | 92 | #[test] 93 | fn test_option_matcher_none() { 94 | let mut matcher = OptionMatcher::none(); 95 | let event = Event { 96 | message: "puppy".to_owned(), 97 | timestamp: "2023-08-25T22:14:30.067641Z".to_owned(), 98 | level: Level::INFO, 99 | fields: Default::default(), 100 | target: "ghciwatch::ghci".to_owned(), 101 | span: Some(Span { 102 | name: "ghci".to_owned(), 103 | fields: Default::default(), 104 | }), 105 | spans: vec![Span { 106 | name: "ghci".to_owned(), 107 | fields: Default::default(), 108 | }], 109 | }; 110 | 111 | assert!(!matcher.matches(&event).unwrap()); 112 | assert!(!matcher 113 | .matches(&Event { 114 | message: "doggy".to_owned(), 115 | ..event 116 | }) 117 | .unwrap()); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/clap/humantime.rs: -------------------------------------------------------------------------------- 1 | //! Adapter for parsing [`Duration`] with a [`clap::builder::Arg::value_parser`]. 2 | 3 | use std::time::Duration; 4 | 5 | use clap::builder::StringValueParser; 6 | use clap::builder::TypedValueParser; 7 | use clap::builder::ValueParserFactory; 8 | use humantime::DurationError; 9 | use miette::LabeledSpan; 10 | use miette::MietteDiagnostic; 11 | use miette::Report; 12 | 13 | use super::value_validation_error; 14 | 15 | /// Adapter for parsing [`Duration`] with a [`clap::builder::Arg::value_parser`]. 16 | #[derive(Default, Clone)] 17 | pub struct DurationValueParser { 18 | inner: StringValueParser, 19 | } 20 | 21 | impl TypedValueParser for DurationValueParser { 22 | type Value = Duration; 23 | 24 | fn parse_ref( 25 | &self, 26 | cmd: &clap::Command, 27 | arg: Option<&clap::Arg>, 28 | value: &std::ffi::OsStr, 29 | ) -> Result { 30 | self.inner.parse_ref(cmd, arg, value).and_then(|str_value| { 31 | humantime::parse_duration(&str_value).map_err(|err| { 32 | let diagnostic = Report::new(MietteDiagnostic { 33 | message: match &err { 34 | DurationError::InvalidCharacter(_) => "Invalid character".to_owned(), 35 | DurationError::NumberExpected(_) => "Expected number".to_owned(), 36 | DurationError::UnknownUnit { unit, .. } => format!("Unknown unit `{unit}`"), 37 | DurationError::NumberOverflow => "Duration is too long".to_owned(), 38 | DurationError::Empty => "No duration given".to_owned(), 39 | }, 40 | code: None, 41 | severity: None, 42 | help: match &err { 43 | DurationError::InvalidCharacter(_) => { 44 | Some("Non-alphanumeric characters are prohibited".to_owned()) 45 | } 46 | DurationError::NumberExpected(_) => { 47 | Some("Did you split a unit into multiple words?".to_owned()) 48 | } 49 | DurationError::UnknownUnit { .. } => Some( 50 | "Valid units include `ms` (milliseconds) and `s` (seconds)".to_owned(), 51 | ), 52 | DurationError::NumberOverflow => None, 53 | DurationError::Empty => None, 54 | }, 55 | url: None, 56 | labels: match err { 57 | DurationError::InvalidCharacter(offset) => Some(vec![LabeledSpan::at( 58 | offset..offset + 1, 59 | "Invalid character", 60 | )]), 61 | DurationError::NumberExpected(offset) => { 62 | Some(vec![LabeledSpan::at(offset..offset + 1, "Expected number")]) 63 | } 64 | DurationError::UnknownUnit { 65 | start, 66 | end, 67 | unit: _, 68 | value: _, 69 | } => Some(vec![LabeledSpan::at(start..end, "Unknown unit")]), 70 | DurationError::NumberOverflow => None, 71 | DurationError::Empty => None, 72 | }, 73 | }) 74 | .with_source_code(str_value.clone()); 75 | value_validation_error(arg, &str_value, format!("{diagnostic:?}")) 76 | }) 77 | }) 78 | } 79 | } 80 | 81 | struct DurationValueParserFactory; 82 | 83 | impl ValueParserFactory for DurationValueParserFactory { 84 | type Parser = DurationValueParser; 85 | 86 | fn value_parser() -> Self::Parser { 87 | Self::Parser::default() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/ghci/parse/lines.rs: -------------------------------------------------------------------------------- 1 | use winnow::ascii::line_ending; 2 | use winnow::ascii::till_line_ending; 3 | use winnow::combinator::alt; 4 | use winnow::combinator::eof; 5 | use winnow::error::ContextError; 6 | use winnow::error::ErrMode; 7 | use winnow::stream::AsChar; 8 | use winnow::stream::Compare; 9 | use winnow::stream::FindSlice; 10 | use winnow::stream::SliceLen; 11 | use winnow::stream::Stream; 12 | use winnow::stream::StreamIsPartial; 13 | use winnow::PResult; 14 | use winnow::Parser; 15 | 16 | /// Parse the rest of a line, including the newline character. 17 | pub fn rest_of_line(input: &mut I) -> PResult<::Slice> 18 | where 19 | I: Stream + StreamIsPartial + for<'i> FindSlice<&'i str> + for<'i> Compare<&'i str>, 20 | ::Token: AsChar, 21 | ::Token: Clone, 22 | ::Slice: SliceLen, 23 | { 24 | until_newline.recognize().parse_next(input) 25 | } 26 | 27 | /// Parse the rest of a line, including the newline character, but do not return the newline 28 | /// character in the output. 29 | pub fn until_newline(input: &mut I) -> PResult<::Slice> 30 | where 31 | I: Stream + StreamIsPartial + for<'i> FindSlice<&'i str> + for<'i> Compare<&'i str>, 32 | ::Token: AsChar, 33 | ::Token: Clone, 34 | ::Slice: SliceLen, 35 | { 36 | let line = till_line_ending.parse_next(input)?; 37 | let ending = line_ending_or_eof.parse_next(input)?; 38 | 39 | if line.slice_len() == 0 && ending.slice_len() == 0 { 40 | Err(ErrMode::Backtrack(ContextError::new())) 41 | } else { 42 | Ok(line) 43 | } 44 | } 45 | 46 | /// Parse a line ending or the end of the file. 47 | /// 48 | /// This is useful for line-oriented parsers that may consume input with no trailing newline 49 | /// character. While this is a [violation of the POSIX spec][posix], VS Code [does it by 50 | /// default][vscode]. 51 | /// 52 | /// [posix]: https://stackoverflow.com/a/729795 53 | /// [vscode]: https://stackoverflow.com/questions/44704968/visual-studio-code-insert-newline-at-the-end-of-files 54 | pub fn line_ending_or_eof(input: &mut I) -> PResult<::Slice> 55 | where 56 | I: Stream + StreamIsPartial + for<'i> FindSlice<&'i str> + for<'i> Compare<&'i str>, 57 | ::Token: AsChar, 58 | ::Token: Clone, 59 | ::Slice: SliceLen, 60 | { 61 | alt((line_ending, eof)).parse_next(input) 62 | } 63 | 64 | #[cfg(test)] 65 | mod tests { 66 | use pretty_assertions::assert_eq; 67 | use winnow::combinator::repeat; 68 | 69 | use super::*; 70 | 71 | #[test] 72 | fn test_parse_rest_of_line() { 73 | assert_eq!(rest_of_line.parse("\n").unwrap(), "\n"); 74 | assert_eq!(rest_of_line.parse("foo\n").unwrap(), "foo\n"); 75 | assert_eq!(rest_of_line.parse("foo bar.?\n").unwrap(), "foo bar.?\n"); 76 | 77 | // Negative cases. 78 | // Two newlines: 79 | assert!(rest_of_line.parse("foo\n\n").is_err()); 80 | } 81 | 82 | #[test] 83 | fn test_parse_until_newline() { 84 | assert_eq!(until_newline.parse("\n").unwrap(), ""); 85 | assert_eq!(until_newline.parse("foo\n").unwrap(), "foo"); 86 | assert_eq!(until_newline.parse("foo bar.?\n").unwrap(), "foo bar.?"); 87 | 88 | // Negative cases. 89 | // Two newlines: 90 | assert!(until_newline.parse("foo\n\n").is_err()); 91 | } 92 | 93 | #[test] 94 | fn test_parse_lines_repeat() { 95 | fn parser(input: &str) -> miette::Result> { 96 | repeat(0.., rest_of_line) 97 | .parse(input) 98 | .map_err(|err| miette::miette!("{err}")) 99 | } 100 | 101 | assert_eq!( 102 | parser("puppy\ndoggy\n").unwrap(), 103 | vec!["puppy\n", "doggy\n"] 104 | ); 105 | assert_eq!(parser("puppy\ndoggy").unwrap(), vec!["puppy\n", "doggy"]); 106 | assert_eq!(parser("dog").unwrap(), vec!["dog"]); 107 | assert_eq!(parser(" \ndog\n").unwrap(), vec![" \n", "dog\n"]); 108 | assert_eq!(parser("\ndog\n").unwrap(), vec!["\n", "dog\n"]); 109 | assert_eq!(parser("\n").unwrap(), vec!["\n"]); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/tui/terminal.rs: -------------------------------------------------------------------------------- 1 | use crossterm::cursor; 2 | use crossterm::event; 3 | use crossterm::terminal; 4 | use miette::miette; 5 | use miette::IntoDiagnostic; 6 | use miette::WrapErr; 7 | use ratatui::prelude::CrosstermBackend; 8 | use ratatui::prelude::Terminal; 9 | use std::io::Stdout; 10 | use std::ops::Deref; 11 | use std::ops::DerefMut; 12 | use std::panic; 13 | use std::sync::atomic::AtomicBool; 14 | use std::sync::atomic::Ordering; 15 | use tracing::instrument; 16 | 17 | /// A wrapper around a [`Terminal`] that disables the terminal's raw mode when it's dropped. 18 | pub struct TerminalGuard { 19 | terminal: Terminal>, 20 | } 21 | 22 | impl Deref for TerminalGuard { 23 | type Target = Terminal>; 24 | 25 | fn deref(&self) -> &Self::Target { 26 | &self.terminal 27 | } 28 | } 29 | 30 | impl DerefMut for TerminalGuard { 31 | fn deref_mut(&mut self) -> &mut Self::Target { 32 | &mut self.terminal 33 | } 34 | } 35 | 36 | impl Drop for TerminalGuard { 37 | fn drop(&mut self) { 38 | if let Err(error) = exit().wrap_err("Failed to exit terminal during drop") { 39 | if std::thread::panicking() { 40 | // Ignore the `Result` if we're already panicking; aborting is undesirable. 41 | tracing::error!("{error}"); 42 | } else { 43 | panic!("{error}"); 44 | } 45 | } 46 | } 47 | } 48 | 49 | /// Are we currently inside a raw-mode terminal? 50 | /// 51 | /// This helps us avoid entering or exiting raw-mode twice. 52 | static INSIDE: AtomicBool = AtomicBool::new(false); 53 | 54 | /// Enter raw-mode for the terminal on stdout, set up a panic hook, etc. 55 | #[instrument(level = "debug")] 56 | pub fn enter() -> miette::Result { 57 | use event::KeyboardEnhancementFlags as KEF; 58 | 59 | if INSIDE.load(Ordering::SeqCst) { 60 | return Err(miette!( 61 | "Cannot enter raw mode; the terminal is already set up" 62 | )); 63 | } 64 | 65 | // Set `INSIDE` immediately so that a partial load is rolled back by `exit()`. 66 | INSIDE.store(true, Ordering::SeqCst); 67 | 68 | let mut stdout = std::io::stdout(); 69 | 70 | terminal::enable_raw_mode() 71 | .into_diagnostic() 72 | .wrap_err("Failed to enable raw mode")?; 73 | 74 | crossterm::execute!( 75 | stdout, 76 | terminal::EnterAlternateScreen, 77 | cursor::Hide, 78 | event::EnableMouseCapture, 79 | event::EnableFocusChange, 80 | event::EnableBracketedPaste, 81 | event::PushKeyboardEnhancementFlags( 82 | KEF::DISAMBIGUATE_ESCAPE_CODES 83 | | KEF::REPORT_EVENT_TYPES 84 | | KEF::REPORT_ALL_KEYS_AS_ESCAPE_CODES 85 | ), 86 | ) 87 | .into_diagnostic() 88 | .wrap_err("Failed to execute crossterm commands")?; 89 | 90 | let previous_hook = panic::take_hook(); 91 | 92 | panic::set_hook(Box::new(move |panic_info| { 93 | // Ignoring the `Result` because we're already panicking; aborting is undesirable 94 | let _ = exit(); 95 | previous_hook(panic_info); 96 | })); 97 | 98 | let backend = CrosstermBackend::new(stdout); 99 | 100 | let terminal = Terminal::new(backend) 101 | .into_diagnostic() 102 | .wrap_err("Failed to create ratatui terminal")?; 103 | 104 | Ok(TerminalGuard { terminal }) 105 | } 106 | 107 | /// Exits terminal raw-mode. 108 | #[instrument(level = "debug")] 109 | pub fn exit() -> miette::Result<()> { 110 | if !INSIDE.load(Ordering::SeqCst) { 111 | return Ok(()); 112 | } 113 | 114 | let mut stdout = std::io::stdout(); 115 | 116 | crossterm::execute!( 117 | stdout, 118 | event::PopKeyboardEnhancementFlags, 119 | event::DisableBracketedPaste, 120 | event::DisableFocusChange, 121 | event::DisableMouseCapture, 122 | cursor::Show, 123 | terminal::LeaveAlternateScreen, 124 | ) 125 | .into_diagnostic() 126 | .wrap_err("Failed to execute crossterm commands")?; 127 | 128 | terminal::disable_raw_mode() 129 | .into_diagnostic() 130 | .wrap_err("Failed to disable raw mode")?; 131 | 132 | INSIDE.store(false, Ordering::SeqCst); 133 | 134 | Ok(()) 135 | } 136 | -------------------------------------------------------------------------------- /tests/error_log.rs: -------------------------------------------------------------------------------- 1 | use expect_test::expect; 2 | use indoc::indoc; 3 | 4 | use test_harness::test; 5 | use test_harness::BaseMatcher; 6 | use test_harness::GhcVersion::*; 7 | use test_harness::GhciWatchBuilder; 8 | 9 | /// Test that `ghciwatch --errors ...` can write the error log. 10 | #[test] 11 | async fn can_write_error_log() { 12 | let error_path = "ghcid.txt"; 13 | let mut session = GhciWatchBuilder::new("tests/data/simple") 14 | .with_args(["--errors", error_path]) 15 | .start() 16 | .await 17 | .expect("ghciwatch starts"); 18 | let error_path = session.path(error_path); 19 | session 20 | .wait_until_ready() 21 | .await 22 | .expect("ghciwatch loads ghci"); 23 | let error_contents = session 24 | .fs() 25 | .read(&error_path) 26 | .await 27 | .expect("ghciwatch writes ghcid.txt"); 28 | expect![[r#" 29 | All good (3 modules) 30 | "#]] 31 | .assert_eq(&error_contents); 32 | } 33 | 34 | /// Test that `ghciwatch --errors ...` can write compilation errors. 35 | /// Then, test that it can reload when modules are changed and will correctly rewrite the error log 36 | /// once it's fixed. 37 | #[test] 38 | async fn can_write_error_log_compilation_errors() { 39 | let error_path = "ghcid.txt"; 40 | let mut session = GhciWatchBuilder::new("tests/data/simple") 41 | .with_args(["--errors", error_path]) 42 | .start() 43 | .await 44 | .expect("ghciwatch starts"); 45 | let error_path = session.path(error_path); 46 | session 47 | .wait_until_ready() 48 | .await 49 | .expect("ghciwatch loads ghci"); 50 | 51 | let new_module = session.path("src/My/Module.hs"); 52 | 53 | session 54 | .fs() 55 | .write( 56 | &new_module, 57 | indoc!( 58 | "module My.Module (myIdent) where 59 | myIdent :: () 60 | myIdent = \"Uh oh!\" 61 | " 62 | ), 63 | ) 64 | .await 65 | .unwrap(); 66 | session 67 | .wait_until_add() 68 | .await 69 | .expect("ghciwatch loads new modules"); 70 | 71 | session 72 | .wait_for_log(BaseMatcher::span_close().in_leaf_spans(["error_log_write"])) 73 | .await 74 | .expect("ghciwatch writes ghcid.txt"); 75 | 76 | session 77 | .wait_for_log(BaseMatcher::reload_completes()) 78 | .await 79 | .expect("ghciwatch finishes reloading"); 80 | 81 | let error_contents = session 82 | .fs() 83 | .read(&error_path) 84 | .await 85 | .expect("ghciwatch writes ghcid.txt"); 86 | 87 | let expected = match session.ghc_version() { 88 | Ghc94 => expect![[r#" 89 | src/My/Module.hs:3:11: error: 90 | * Couldn't match type `[Char]' with `()' 91 | Expected: () 92 | Actual: String 93 | * In the expression: "Uh oh!" 94 | In an equation for `myIdent': myIdent = "Uh oh!" 95 | | 96 | 3 | myIdent = "Uh oh!" 97 | | ^^^^^^^^ 98 | "#]], 99 | Ghc96 | Ghc98 | Ghc910 | Ghc912 => expect![[r#" 100 | src/My/Module.hs:3:11: error: [GHC-83865] 101 | * Couldn't match type `[Char]' with `()' 102 | Expected: () 103 | Actual: String 104 | * In the expression: "Uh oh!" 105 | In an equation for `myIdent': myIdent = "Uh oh!" 106 | | 107 | 3 | myIdent = "Uh oh!" 108 | | ^^^^^^^^ 109 | "#]], 110 | }; 111 | 112 | expected.assert_eq(&error_contents); 113 | 114 | session 115 | .fs() 116 | .replace(&new_module, "myIdent = \"Uh oh!\"", "myIdent = ()") 117 | .await 118 | .unwrap(); 119 | 120 | session 121 | .wait_until_reload() 122 | .await 123 | .expect("ghciwatch reloads on changes"); 124 | 125 | session 126 | .wait_for_log(BaseMatcher::span_close().in_leaf_spans(["error_log_write"])) 127 | .await 128 | .expect("ghciwatch writes ghcid.txt"); 129 | 130 | let error_contents = session 131 | .fs() 132 | .read(&error_path) 133 | .await 134 | .expect("ghciwatch writes ghcid.txt"); 135 | 136 | expect![[r#" 137 | All good (4 modules) 138 | "#]] 139 | .assert_eq(&error_contents); 140 | } 141 | -------------------------------------------------------------------------------- /src/ghci/parse/ghc_message/no_location_info_diagnostic.rs: -------------------------------------------------------------------------------- 1 | use winnow::ascii::space0; 2 | use winnow::ascii::space1; 3 | use winnow::PResult; 4 | use winnow::Parser; 5 | 6 | use crate::ghci::parse::ghc_message::message_body::parse_message_body; 7 | use crate::ghci::parse::ghc_message::position; 8 | use crate::ghci::parse::ghc_message::severity; 9 | 10 | use super::GhcDiagnostic; 11 | 12 | /// Parse a message like this: 13 | /// 14 | /// ```text 15 | /// : error: 16 | /// Could not find module ‘Example’ 17 | /// It is not a module in the current program, or in any known package. 18 | /// ``` 19 | pub fn no_location_info_diagnostic(input: &mut &str) -> PResult { 20 | let _ = position::parse_unhelpful_position.parse_next(input)?; 21 | let _ = space1.parse_next(input)?; 22 | let severity = severity::parse_severity_colon.parse_next(input)?; 23 | let _ = space0.parse_next(input)?; 24 | let message = parse_message_body.parse_next(input)?; 25 | 26 | Ok(GhcDiagnostic { 27 | severity, 28 | path: None, 29 | span: Default::default(), 30 | message: message.to_owned(), 31 | }) 32 | } 33 | 34 | #[cfg(test)] 35 | mod tests { 36 | use super::*; 37 | 38 | use indoc::indoc; 39 | use pretty_assertions::assert_eq; 40 | use severity::Severity; 41 | 42 | #[test] 43 | fn test_parse_no_location_info_message() { 44 | // Error message from here: https://github.com/commercialhaskell/stack/issues/3582 45 | let message = indoc!( 46 | " 47 | : error: 48 | Could not find module ‘Example’ 49 | It is not a module in the current program, or in any known package. 50 | " 51 | ); 52 | assert_eq!( 53 | no_location_info_diagnostic.parse(message).unwrap(), 54 | GhcDiagnostic { 55 | severity: Severity::Error, 56 | path: None, 57 | span: Default::default(), 58 | message: "\n Could not find module ‘Example’\ 59 | \n It is not a module in the current program, or in any known package.\ 60 | \n" 61 | .into() 62 | } 63 | ); 64 | 65 | assert_eq!( 66 | no_location_info_diagnostic 67 | .parse(indoc!( 68 | " 69 | : error: [GHC-29235] 70 | module 'dwb-0-inplace-test-dev:Foo' is defined in multiple files: src/Foo.hs 71 | src/Foo.hs 72 | " 73 | )) 74 | .unwrap(), 75 | GhcDiagnostic { 76 | severity: Severity::Error, 77 | path: None, 78 | span: Default::default(), 79 | message: indoc!( 80 | " 81 | [GHC-29235] 82 | module 'dwb-0-inplace-test-dev:Foo' is defined in multiple files: src/Foo.hs 83 | src/Foo.hs 84 | " 85 | ) 86 | .into() 87 | } 88 | ); 89 | 90 | // Shouldn't parse another error. 91 | assert!(no_location_info_diagnostic 92 | .parse(indoc!( 93 | " 94 | : error: [GHC-29235] 95 | module 'dwb-0-inplace-test-dev:Foo' is defined in multiple files: src/Foo.hs 96 | src/Foo.hs 97 | Error: Uh oh! 98 | " 99 | )) 100 | .is_err()); 101 | } 102 | 103 | #[test] 104 | fn no_location_info_diagnostic_display() { 105 | // Error message from here: https://github.com/commercialhaskell/stack/issues/3582 106 | let message = indoc!( 107 | " 108 | : error: 109 | Could not find module ‘Example’ 110 | It is not a module in the current program, or in any known package. 111 | " 112 | ); 113 | assert_eq!( 114 | no_location_info_diagnostic 115 | .parse(message) 116 | .unwrap() 117 | .to_string(), 118 | message 119 | ); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/ghci/parse/ghc_message/message_body.rs: -------------------------------------------------------------------------------- 1 | use winnow::ascii::space1; 2 | use winnow::combinator::alt; 3 | use winnow::combinator::repeat; 4 | use winnow::token::take_while; 5 | use winnow::PResult; 6 | use winnow::Parser; 7 | 8 | use crate::ghci::parse::lines::rest_of_line; 9 | 10 | /// Parse the rest of the line as a GHC message and then parse any additional lines after that. 11 | pub fn parse_message_body<'i>(input: &mut &'i str) -> PResult<&'i str> { 12 | ( 13 | rest_of_line, 14 | repeat::<_, _, (), _, _>(0.., parse_message_body_line).recognize(), 15 | ) 16 | .recognize() 17 | .parse_next(input) 18 | } 19 | 20 | /// Parse a GHC diagnostic message body line and newline. 21 | /// 22 | /// Message body lines are indented or start with a line number before a pipe `|`. 23 | pub fn parse_message_body_line<'i>(input: &mut &'i str) -> PResult<&'i str> { 24 | ( 25 | alt(( 26 | space1.void(), 27 | (take_while(1.., (' ', '\t', '0'..='9')), "|").void(), 28 | )), 29 | rest_of_line, 30 | ) 31 | .recognize() 32 | .parse_next(input) 33 | } 34 | 35 | #[cfg(test)] 36 | mod tests { 37 | use super::*; 38 | 39 | use indoc::indoc; 40 | use pretty_assertions::assert_eq; 41 | 42 | #[test] 43 | fn test_parse_message_body_line() { 44 | assert_eq!( 45 | parse_message_body_line 46 | .parse(" • Can't make a derived instance of ‘MyClass MyType’:\n") 47 | .unwrap(), 48 | " • Can't make a derived instance of ‘MyClass MyType’:\n" 49 | ); 50 | assert_eq!( 51 | parse_message_body_line 52 | .parse("6 | deriving MyClass\n") 53 | .unwrap(), 54 | "6 | deriving MyClass\n" 55 | ); 56 | assert_eq!(parse_message_body_line.parse(" |\n").unwrap(), " |\n"); 57 | assert_eq!( 58 | parse_message_body_line 59 | .parse(" Suggested fix: Perhaps you intended to use DeriveAnyClass\n") 60 | .unwrap(), 61 | " Suggested fix: Perhaps you intended to use DeriveAnyClass\n" 62 | ); 63 | assert_eq!(parse_message_body_line.parse(" \n").unwrap(), " \n"); 64 | 65 | // Negative cases. 66 | // Blank line: 67 | assert!(parse_message_body_line.parse("\n").is_err()); 68 | // Two lines: 69 | assert!(parse_message_body_line.parse(" \n\n").is_err()); 70 | // New error message: 71 | assert!(parse_message_body_line 72 | .parse("Foo.hs:8:16: Error: The syntax is wrong :(\n") 73 | .is_err()); 74 | assert!(parse_message_body_line 75 | .parse("[1 of 2] Compiling Foo ( Foo.hs, interpreted )\n") 76 | .is_err()); 77 | } 78 | 79 | #[test] 80 | fn test_parse_message_body() { 81 | let src = indoc!( 82 | " • Can't make a derived instance of ‘MyClass MyType’: 83 | ‘MyClass’ is not a stock derivable class (Eq, Show, etc.) 84 | • In the data declaration for ‘MyType’ 85 | Suggested fix: Perhaps you intended to use DeriveAnyClass 86 | | 87 | 6 | deriving MyClass 88 | | ^^^^^^^ 89 | " 90 | ); 91 | assert_eq!(parse_message_body.parse(src).unwrap(), src); 92 | 93 | let src = indoc!( 94 | "[GHC-00158] 95 | • Can't make a derived instance of ‘MyClass MyType’: 96 | ‘MyClass’ is not a stock derivable class (Eq, Show, etc.) 97 | • In the data declaration for ‘MyType’ 98 | Suggested fix: Perhaps you intended to use DeriveAnyClass 99 | | 100 | 6 | deriving MyClass 101 | | ^^^^^^^ 102 | " 103 | ); 104 | assert_eq!(parse_message_body.parse(src).unwrap(), src); 105 | 106 | // Don't parse another error. 107 | assert!(parse_message_body 108 | .parse(indoc!( 109 | "[GHC-00158] 110 | • Can't make a derived instance of ‘MyClass MyType’: 111 | ‘MyClass’ is not a stock derivable class (Eq, Show, etc.) 112 | • In the data declaration for ‘MyType’ 113 | Suggested fix: Perhaps you intended to use DeriveAnyClass 114 | | 115 | 6 | deriving MyClass 116 | | ^^^^^^^ 117 | 118 | Foo.hs:4:1: Error: I don't like it 119 | " 120 | )) 121 | .is_err()); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/tracing/mod.rs: -------------------------------------------------------------------------------- 1 | //! Extensions and utilities for the [`tracing`] crate. 2 | use std::io::Write; 3 | 4 | use camino::Utf8Path; 5 | use miette::Context; 6 | use miette::IntoDiagnostic; 7 | use tokio::io::DuplexStream; 8 | use tokio_util::io::SyncIoBridge; 9 | use tracing_appender::non_blocking::WorkerGuard; 10 | use tracing_subscriber::fmt; 11 | use tracing_subscriber::fmt::format::FmtSpan; 12 | use tracing_subscriber::fmt::format::JsonFields; 13 | use tracing_subscriber::layer::SubscriberExt; 14 | use tracing_subscriber::util::SubscriberInitExt; 15 | use tracing_subscriber::EnvFilter; 16 | use tracing_subscriber::Layer; 17 | 18 | use crate::cli::Opts; 19 | 20 | /// Options for initializing the [`tracing`] logging framework. This is like a lower-effort builder 21 | /// interface, mostly provided because Rust tragically lacks named arguments. 22 | pub struct TracingOpts<'opts> { 23 | /// Filter directives to control which events are logged. 24 | pub filter_directives: &'opts str, 25 | /// Control which span events are logged. 26 | pub trace_spans: &'opts [FmtSpan], 27 | /// If given, log as JSON to the given path. 28 | pub json_log_path: Option<&'opts Utf8Path>, 29 | /// Are we running in TUI mode? 30 | /// 31 | /// A `(reader, writer)` pair to write to the TUI. 32 | pub tui: Option<(DuplexStream, DuplexStream)>, // Mutex? 33 | } 34 | 35 | impl<'opts> TracingOpts<'opts> { 36 | /// Construct options for initializing the [`tracing`] logging framework from parsed 37 | /// commmand-line interface arguments as [`Opts`]. 38 | pub fn from_cli(opts: &'opts Opts) -> Self { 39 | Self { 40 | filter_directives: &opts.logging.log_filter, 41 | trace_spans: &opts.logging.trace_spans, 42 | json_log_path: opts.logging.log_json.as_deref(), 43 | tui: if opts.tui { 44 | Some(tokio::io::duplex(crate::buffers::TRACING_BUFFER_CAPACITY)) 45 | } else { 46 | None 47 | }, 48 | } 49 | } 50 | 51 | /// Initialize the [`tracing`] logging framework. 52 | pub fn install(&mut self) -> miette::Result<(Option, WorkerGuard)> { 53 | let env_filter = EnvFilter::try_new(self.filter_directives).into_diagnostic()?; 54 | 55 | let fmt_span = self 56 | .trace_spans 57 | .iter() 58 | .fold(FmtSpan::NONE, |result, item| result | item.clone()); 59 | 60 | let mut tracing_reader = None; 61 | let tracing_writer: Box = match self.tui.take() { 62 | Some((reader, writer)) => { 63 | tracing_reader = Some(reader); 64 | Box::new(SyncIoBridge::new(writer)) 65 | } 66 | None => Box::new(std::io::stderr()), 67 | }; 68 | let (tracing_writer, worker_guard) = tracing_appender::non_blocking(tracing_writer); 69 | 70 | let human_layer = tracing_human_layer::HumanLayer::default() 71 | .with_span_events(fmt_span.clone()) 72 | .with_color_output( 73 | supports_color::on(supports_color::Stream::Stdout) 74 | .map(|colors| colors.has_basic) 75 | .unwrap_or(false), 76 | ) 77 | .with_output_writer(tracing_writer) 78 | .with_filter(env_filter); 79 | 80 | let registry = tracing_subscriber::registry(); 81 | 82 | let registry = registry.with(human_layer); 83 | 84 | match &self.json_log_path { 85 | Some(path) => { 86 | let json_layer = tracing_json_layer(self.filter_directives, path, fmt_span)?; 87 | registry.with(json_layer).init(); 88 | } 89 | None => { 90 | registry.init(); 91 | } 92 | } 93 | 94 | Ok((tracing_reader, worker_guard)) 95 | } 96 | } 97 | 98 | fn tracing_json_layer( 99 | filter_directives: &str, 100 | log_path: &Utf8Path, 101 | fmt_span: FmtSpan, 102 | ) -> miette::Result + Send + Sync + 'static>> 103 | where 104 | S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>, 105 | { 106 | let file = std::fs::File::create(log_path) 107 | .into_diagnostic() 108 | .wrap_err_with(|| format!("Failed to open {log_path:?}"))?; 109 | 110 | let env_filter = EnvFilter::try_new(filter_directives).into_diagnostic()?; 111 | 112 | let layer = fmt::layer() 113 | .with_span_events(fmt_span) 114 | .event_format(fmt::format::json()) 115 | .fmt_fields(JsonFields::new()) 116 | .with_writer(file) 117 | .with_filter(env_filter) 118 | .boxed(); 119 | 120 | Ok(layer) 121 | } 122 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "ghci-based file watcher and recompiler for Haskell projects"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | crane.url = "github:ipetkov/crane"; 7 | systems.url = "github:nix-systems/default"; 8 | rust-overlay = { 9 | url = "github:oxalica/rust-overlay"; 10 | inputs = { 11 | nixpkgs.follows = "nixpkgs"; 12 | }; 13 | }; 14 | advisory-db = { 15 | url = "github:rustsec/advisory-db"; 16 | flake = false; 17 | }; 18 | flake-compat = { 19 | url = "github:edolstra/flake-compat"; 20 | flake = false; 21 | }; 22 | }; 23 | 24 | nixConfig = { 25 | extra-substituters = ["https://cache.garnix.io"]; 26 | extra-trusted-substituters = ["https://cache.garnix.io"]; 27 | extra-trusted-public-keys = ["cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g="]; 28 | }; 29 | 30 | outputs = inputs @ { 31 | self, 32 | nixpkgs, 33 | crane, 34 | systems, 35 | rust-overlay, 36 | advisory-db, 37 | flake-compat, 38 | }: let 39 | eachSystem = nixpkgs.lib.genAttrs (import systems); 40 | 41 | makePkgs = { 42 | localSystem, 43 | crossSystem ? localSystem, 44 | }: 45 | import nixpkgs { 46 | inherit localSystem crossSystem; 47 | overlays = [ 48 | (import rust-overlay) 49 | ( 50 | final: prev: { 51 | # TODO: Bump the Rust version here... 52 | rustToolchain = final.pkgsBuildHost.rust-bin.stable."1.87.0".default.override { 53 | targets = 54 | final.lib.optionals final.stdenv.targetPlatform.isDarwin [ 55 | "x86_64-apple-darwin" 56 | "aarch64-apple-darwin" 57 | ] 58 | ++ final.lib.optionals final.stdenv.targetPlatform.isLinux [ 59 | "x86_64-unknown-linux-musl" 60 | "aarch64-unknown-linux-musl" 61 | ]; 62 | extensions = ["llvm-tools-preview"]; 63 | }; 64 | 65 | craneLib = (crane.mkLib final).overrideToolchain final.rustToolchain; 66 | } 67 | ) 68 | ]; 69 | }; 70 | 71 | # GHC versions to include in the environment for integration tests. 72 | # Keep this in sync with `./test-harness/src/ghc_version.rs`. 73 | ghcVersions = [ 74 | "ghc96" 75 | "ghc98" 76 | "ghc910" 77 | "ghc912" 78 | ]; 79 | in { 80 | _pkgs = eachSystem (localSystem: makePkgs {inherit localSystem;}); 81 | 82 | localPkgs = eachSystem ( 83 | localSystem: 84 | self._pkgs.${localSystem}.callPackage ./nix/makePackages.nix {inherit inputs;} 85 | ); 86 | 87 | packages = eachSystem ( 88 | localSystem: let 89 | inherit (nixpkgs) lib; 90 | localPkgs = self.localPkgs.${localSystem}; 91 | pkgs = self._pkgs.${localSystem}; 92 | ghciwatch = localPkgs.ghciwatch.override { 93 | inherit ghcVersions; 94 | }; 95 | in 96 | (lib.filterAttrs (name: value: lib.isDerivation value) localPkgs) 97 | // { 98 | inherit ghciwatch; 99 | default = ghciwatch; 100 | ghciwatch-tests = ghciwatch.checks.ghciwatch-tests; 101 | ghciwatch-user-manual = ghciwatch.user-manual; 102 | ghciwatch-user-manual-tar-xz = ghciwatch.user-manual-tar-xz; 103 | 104 | # This lets us use `nix run .#cargo` to run Cargo commands without 105 | # loading the entire `nix develop` shell (which includes 106 | # `rust-analyzer` and four separate versions of GHC) 107 | # 108 | # Used in `.github/workflows/release.yaml`. 109 | cargo = pkgs.rustToolchain.overrideAttrs { 110 | pname = "cargo"; 111 | }; 112 | } 113 | // (pkgs.lib.optionalAttrs pkgs.stdenv.isLinux { 114 | # ghciwatch cross-compiled to aarch64-linux. 115 | ghciwatch-aarch64-linux = let 116 | crossPkgs = makePkgs { 117 | inherit localSystem; 118 | crossSystem = "aarch64-linux"; 119 | }; 120 | packages = crossPkgs.callPackage ./nix/makePackages.nix {inherit inputs;}; 121 | in 122 | packages.ghciwatch.override {inherit ghcVersions;}; 123 | }) 124 | ); 125 | 126 | checks = eachSystem ( 127 | system: 128 | builtins.removeAttrs 129 | self.localPkgs.${system}.allChecks 130 | # CI and `nix flake check` complain that these are not derivations. 131 | ["override" "overrideDerivation"] 132 | ); 133 | 134 | devShells = eachSystem (system: { 135 | default = self.packages.${system}.default.devShell; 136 | }); 137 | }; 138 | } 139 | -------------------------------------------------------------------------------- /src/ghci/stderr.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | use std::time::Instant; 3 | 4 | use backoff::backoff::Backoff; 5 | use backoff::ExponentialBackoff; 6 | use miette::Context; 7 | use miette::IntoDiagnostic; 8 | use tokio::io::AsyncWriteExt; 9 | use tokio::io::BufReader; 10 | use tokio::io::Lines; 11 | use tokio::process::ChildStderr; 12 | use tokio::sync::mpsc; 13 | use tokio::sync::oneshot; 14 | use tracing::instrument; 15 | 16 | use crate::shutdown::ShutdownHandle; 17 | 18 | use super::writer::GhciWriter; 19 | 20 | /// An event sent to a `ghci` session's stderr channel. 21 | #[derive(Debug)] 22 | pub enum StderrEvent { 23 | /// Clear the buffer contents. 24 | ClearBuffer, 25 | 26 | /// Get the buffer contents since the last `ClearBuffer` event. 27 | GetBuffer { sender: oneshot::Sender }, 28 | } 29 | 30 | pub struct GhciStderr { 31 | pub shutdown: ShutdownHandle, 32 | pub reader: Lines>, 33 | pub writer: GhciWriter, 34 | pub receiver: mpsc::Receiver, 35 | /// Output buffer. 36 | pub buffer: String, 37 | } 38 | 39 | impl GhciStderr { 40 | #[instrument(skip_all, name = "stderr", level = "debug")] 41 | pub async fn run(mut self) -> miette::Result<()> { 42 | let mut backoff = ExponentialBackoff::default(); 43 | while let Some(duration) = backoff.next_backoff() { 44 | match self.run_inner().await { 45 | Ok(()) => { 46 | // MPSC channel closed, probably a graceful shutdown? 47 | break; 48 | } 49 | Err(err) => { 50 | tracing::error!("{err:?}"); 51 | } 52 | } 53 | 54 | tracing::debug!("Waiting {duration:?} before retrying"); 55 | tokio::time::sleep(duration).await; 56 | } 57 | 58 | Ok(()) 59 | } 60 | 61 | pub async fn run_inner(&mut self) -> miette::Result<()> { 62 | loop { 63 | tokio::select! { 64 | Ok(Some(line)) = self.reader.next_line() => { 65 | self.ingest_line(line).await?; 66 | } 67 | Some(event) = self.receiver.recv() => { 68 | self.dispatch(event).await?; 69 | } 70 | _ = self.shutdown.on_shutdown_requested() => { 71 | // Graceful exit. 72 | break; 73 | } 74 | else => { 75 | // Graceful exit. 76 | break; 77 | } 78 | } 79 | } 80 | Ok(()) 81 | } 82 | 83 | async fn dispatch(&mut self, event: StderrEvent) -> miette::Result<()> { 84 | match event { 85 | StderrEvent::ClearBuffer => { 86 | self.clear_buffer().await; 87 | } 88 | StderrEvent::GetBuffer { sender } => { 89 | self.get_buffer(sender).await?; 90 | } 91 | } 92 | 93 | Ok(()) 94 | } 95 | 96 | #[instrument(skip(self), level = "trace")] 97 | async fn ingest_line(&mut self, mut line: String) -> miette::Result<()> { 98 | tracing::debug!(line, "Read stderr line"); 99 | line.push('\n'); 100 | self.buffer.push_str(&line); 101 | self.writer 102 | .write_all(line.as_bytes()) 103 | .await 104 | .into_diagnostic()?; 105 | Ok(()) 106 | } 107 | 108 | #[instrument(skip(self), level = "trace")] 109 | async fn clear_buffer(&mut self) { 110 | self.buffer.clear(); 111 | } 112 | 113 | #[instrument(skip(self, sender), level = "debug")] 114 | async fn get_buffer(&mut self, sender: oneshot::Sender) -> miette::Result<()> { 115 | // Read lines from the stderr stream until we can't read a line within 0.05 seconds. 116 | // 117 | // This helps make sure we've read all the available data. 118 | // 119 | // In testing, this takes ~52ms. 120 | let start_instant = Instant::now(); 121 | while let Ok(maybe_line) = 122 | tokio::time::timeout(Duration::from_millis(50), self.reader.next_line()).await 123 | { 124 | match maybe_line 125 | .into_diagnostic() 126 | .wrap_err("Failed to read stderr line")? 127 | { 128 | Some(line) => { 129 | self.ingest_line(line).await?; 130 | } 131 | None => { 132 | tracing::debug!("No more lines available from stderr"); 133 | } 134 | } 135 | } 136 | tracing::debug!("Drained stderr buffer in {:.2?}", start_instant.elapsed()); 137 | 138 | // TODO: Does it make more sense to clear the buffer here? 139 | let _ = sender.send(self.buffer.clone()); 140 | 141 | Ok(()) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /test-harness/src/tracing_json.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fmt::Display; 3 | 4 | use miette::Context; 5 | use miette::IntoDiagnostic; 6 | use serde::Deserialize; 7 | use tracing::Level; 8 | 9 | /// A [`tracing`] log event, deserialized from JSON log output. 10 | #[derive(Deserialize, Debug, Clone)] 11 | #[serde(try_from = "JsonEvent")] 12 | pub struct Event { 13 | /// The event timestamp. 14 | pub timestamp: String, 15 | /// The level the event was logged at. 16 | pub level: Level, 17 | /// The log message. May be a span lifecycle event like `new` or `close`. 18 | pub message: String, 19 | /// The event fields; extra data attached to this event. 20 | pub fields: HashMap, 21 | /// The target, usually the module where the event was logged from. 22 | pub target: String, 23 | /// The span the event was logged in, if any. 24 | pub span: Option, 25 | /// Spans the event is nested in, beyond the first `span`. 26 | /// 27 | /// These are listed from the outside in (root to leaf). 28 | pub spans: Vec, 29 | } 30 | 31 | impl Event { 32 | /// Get an iterator over this event's spans, from the outside in (root to leaf). 33 | pub fn spans(&self) -> impl DoubleEndedIterator { 34 | self.spans.iter().chain({ 35 | // The `new`, `exit`, and `close` span lifecycle events aren't emitted from inside the 36 | // relevant span, so the span isn't listed in `spans`. Instead, the relevant span is in 37 | // the `span` field. 38 | // 39 | // In all other cases, the `span` field is identical to the last entry of the `spans` 40 | // field. 41 | // 42 | // Note that this will false-positive if there are any events with these strings as the 43 | // message, but that's fine. 44 | // 45 | // We could (and perhaps should) patch `tracing-subscriber` for this, or better yet 46 | // write our own JSON `tracing` exporter, but this is fine for now. 47 | if ["new", "exit", "close"].contains(&self.message.as_str()) { 48 | self.span.iter() 49 | } else { 50 | None.iter() 51 | } 52 | }) 53 | } 54 | } 55 | 56 | impl Display for Event { 57 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 58 | write!(f, "{} {}", self.level, self.target)?; 59 | let spans = itertools::join(self.spans(), ">"); 60 | if !spans.is_empty() { 61 | write!(f, " [{spans}]")?; 62 | } 63 | write!(f, ": {}", self.message)?; 64 | if !self.fields.is_empty() { 65 | write!(f, " {}", display_map(&self.fields))?; 66 | } 67 | Ok(()) 68 | } 69 | } 70 | 71 | impl TryFrom for Event { 72 | type Error = miette::Report; 73 | 74 | fn try_from(event: JsonEvent) -> Result { 75 | Ok(Self { 76 | timestamp: event.timestamp, 77 | level: event 78 | .level 79 | .parse() 80 | .into_diagnostic() 81 | .wrap_err_with(|| format!("Failed to parse tracing level: {}", event.level))?, 82 | message: event.fields.message, 83 | fields: event.fields.fields, 84 | target: event.target, 85 | span: event.span, 86 | spans: event.spans, 87 | }) 88 | } 89 | } 90 | 91 | #[derive(Deserialize)] 92 | struct JsonEvent { 93 | timestamp: String, 94 | level: String, 95 | fields: Fields, 96 | target: String, 97 | span: Option, 98 | #[serde(default)] 99 | spans: Vec, 100 | } 101 | 102 | /// A span (a region containing log events and other spans). 103 | #[derive(Deserialize, Debug, Clone)] 104 | pub struct Span { 105 | /// The span's name. 106 | pub name: String, 107 | /// The span's fields; extra data attached to this span. 108 | #[serde(flatten)] 109 | pub fields: HashMap, 110 | } 111 | 112 | impl Span { 113 | #[cfg(test)] 114 | pub fn new(name: impl Display) -> Self { 115 | Self { 116 | name: name.to_string(), 117 | fields: Default::default(), 118 | } 119 | } 120 | } 121 | 122 | impl Display for Span { 123 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 124 | write!(f, "{}{}", self.name, display_map(&self.fields)) 125 | } 126 | } 127 | 128 | #[derive(Deserialize, Debug)] 129 | struct Fields { 130 | message: String, 131 | #[serde(flatten)] 132 | fields: HashMap, 133 | } 134 | 135 | fn display_map(hashmap: &HashMap) -> String { 136 | if hashmap.is_empty() { 137 | String::new() 138 | } else { 139 | format!( 140 | "{{{}}}", 141 | itertools::join( 142 | hashmap 143 | .iter() 144 | .map(|(name, value)| format!("{name}={value}")), 145 | ", ", 146 | ) 147 | ) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /.github/workflows/version.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # This workflow runs when PRs labeled `major`, `minor`, or `patch` are closed 3 | # and increments version numbers. Then, it opens a PR labeled `release` for the 4 | # changes. When that PR is merged, a release is created (see `release.yaml`). 5 | # 6 | # Are you here because I left Mercury and now my personal access token is 7 | # invalid for workflows, breaking CI? You'll want to go to 8 | # https://github.com/MercuryTechnologies/ghciwatch/settings/secrets/actions 9 | # and update the `REPO_GITHUB_TOKEN` secret to a new, valid token. 10 | 11 | on: 12 | pull_request_target: 13 | types: 14 | - closed 15 | branches: 16 | - main 17 | 18 | name: Update versions and create release PR 19 | 20 | jobs: 21 | # We make `if_merged` a `needs:` of the other jobs here to only run this 22 | # workflow on merged PRs. 23 | if_merged: 24 | name: Check that PR was merged and not closed 25 | if: github.event.pull_request.merged == true 26 | && ( contains(github.event.pull_request.labels.*.name, 'major') 27 | || contains(github.event.pull_request.labels.*.name, 'minor') 28 | || contains(github.event.pull_request.labels.*.name, 'patch') 29 | ) 30 | runs-on: ubuntu-latest 31 | steps: 32 | - run: | 33 | echo "This is a canonical hack to run GitHub Actions on merged PRs" 34 | echo "See: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#running-your-pull_request-workflow-when-a-pull-request-merges" 35 | 36 | bump_type: 37 | name: Determine version bump type 38 | needs: if_merged 39 | runs-on: ubuntu-latest 40 | outputs: 41 | bump_type: ${{ steps.bump_type.outputs.bump_type }} 42 | steps: 43 | - name: Set output 44 | id: bump_type 45 | env: 46 | is_major: ${{ contains(github.event.pull_request.labels.*.name, 'major') }} 47 | is_minor: ${{ contains(github.event.pull_request.labels.*.name, 'minor') }} 48 | is_patch: ${{ contains(github.event.pull_request.labels.*.name, 'patch') }} 49 | run: | 50 | if [[ "$is_major" == "true" ]]; then 51 | echo "bump_type=major" >> "$GITHUB_OUTPUT" 52 | elif [[ "$is_minor" == "true" ]]; then 53 | echo "bump_type=minor" >> "$GITHUB_OUTPUT" 54 | elif [[ "$is_patch" == "true" ]]; then 55 | echo "bump_type=patch" >> "$GITHUB_OUTPUT" 56 | fi 57 | 58 | version: 59 | name: Bump version and create release PR 60 | needs: 61 | - if_merged 62 | - bump_type 63 | runs-on: ubuntu-latest 64 | steps: 65 | - name: Checkout 66 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 67 | with: 68 | # Fetch all history/tags (needed to compute versions) 69 | fetch-depth: 0 70 | 71 | - uses: cachix/install-nix-action@7be5dee1421f63d07e71ce6e0a9f8a4b07c2a487 # v31.6.1 72 | with: 73 | github_access_token: ${{ secrets.GITHUB_TOKEN }} 74 | extra_nix_config: | 75 | extra-experimental-features = nix-command flakes 76 | accept-flake-config = true 77 | 78 | - name: Get old version number 79 | id: old_cargo_metadata 80 | run: echo "version=$(nix run .#get-crate-version)" >> "$GITHUB_OUTPUT" 81 | 82 | - name: Increment `Cargo.toml` version 83 | run: nix run .#make-release-commit -- ${{ needs.bump_type.outputs.bump_type }} 84 | 85 | - name: Get new version number 86 | id: new_cargo_metadata 87 | run: echo "version=$(nix run .#get-crate-version)" >> "$GITHUB_OUTPUT" 88 | 89 | - name: Create release PR 90 | id: release_pr 91 | uses: peter-evans/create-pull-request@4e1beaa7521e8b457b572c090b25bd3db56bf1c5 # v5.0.3 92 | with: 93 | # We push with the repo-scoped GitHub token to avoid branch 94 | # protections. This token is tied to my account (@9999years) which is 95 | # excluded from branch protection restrictions. 96 | # 97 | # I'd love a better way of implementing this but GitHub doesn't have 98 | # one: https://github.com/github-community/community/discussions/13836 99 | # 100 | # Also, PRs created with the default `secrets.GITHUB_TOKEN` won't 101 | # trigger `pull_request` workflows, so regular CI won't run either. 102 | # See: https://github.com/orgs/community/discussions/65321 103 | token: ${{ secrets.REPO_GITHUB_TOKEN }} 104 | branch: release/${{ steps.new_cargo_metadata.outputs.version }} 105 | delete-branch: true 106 | base: main 107 | title: Release version ${{ steps.new_cargo_metadata.outputs.version }} 108 | body: | 109 | Update version to ${{ steps.new_cargo_metadata.outputs.version }} with [cargo-release](https://github.com/crate-ci/cargo-release). 110 | Merge this PR to build and publish a new release. 111 | labels: release 112 | 113 | - name: Comment on PR with link to release PR 114 | uses: peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d # v2.1.1 115 | with: 116 | issue-number: ${{ github.event.pull_request.number }} 117 | body: | 118 | [A PR to release these changes has been created, bumping the version from ${{ steps.old_cargo_metadata.outputs.version }} to ${{ steps.new_cargo_metadata.outputs.version }}.][pr] 119 | 120 | [pr]: ${{ steps.release_pr.outputs.pull-request-url }} 121 | --------------------------------------------------------------------------------