GHC plugin to desugar ! into do-notation.
A plugin for GHC which takes expressions prefixed with a ! and effectively takes them out of their monadic context, by creating bind statements in the do-block surrounding the expression. Inspired by Idris's !-notation. For more information, see README.md.
Monadic Bang
This is a GHC Parser plugin for GHC 9.4 and above, intended to make monadic code within do
-blocks more concise and nicer to work with. Works with HLS.
This is heavily inspired by Idris's !-notation, but with some important differences.
Contents
Motivating Examples
Let's look at a few examples where Haskell syntax can be a bit annoying when it comes to monads - and what this plugin allows you to write instead:
When you use Reader
or State
, you will often have to use <-
to bind fairly simple expressions:
launchMissile :: StateT Int IO ()
launchMissile = do
count <- get
liftIO . putStrLn $ "Missile no. " <> show count <> " has been launched"
modify' (+ 1)
help :: Reader Config String
help = do
manualLink <- asks (.links.manual)
email <- asks (.contact.email)
pure $
"You can find help by going to " <> manualLink <>
" or writing us at " <> email
With Monadic Bang, you can instead write
launchMissile :: StateT Int IO ()
launchMissile = do
liftIO . putStrLn $ "Missile no. " <> show !get <> " has been launched"
modify' (+ 1)
help :: Reader Config String
help = do
pure $
"You can find help by going to " <> (!ask).links.manual <>
" or writing us at " <> (!ask).contact.email
With IORefs
, STRefs
, mutable arrays, and so on, you'll often have to write code that looks like this, having to use somewhat redundant variable names:
addIORefs :: IORef Int -> IORef Int -> IO Int
addIORefs aRef bRef = do
a <- readIORef aRef
b <- readIORef bRef
pure $ a + b
With Monadic Bang, you can write
addIORefs :: IORef Int -> IORef Int -> IO Int
addIORefs a b = do pure $ !(readIORef a) + !(readIORef b)
Implicit parameter definitions have somewhat more limited syntax than regular definitions: You can't write something like ?foo <- action
.
That lead me to have to write this in a Vulkan program:
initQueues = do
let getQueue = getDeviceQueue ?device
graphicsQueue <- getQueue ?graphicsQueueFamily 0
presentQueue <- getQueue ?presentQueueFamily 0
computeQueue <- getQueue ?computeQueueFamily 1
let ?graphicsQueue = graphicsQueue
?presentQueue = presentQueue
?computeQueue = computeQueue
pure Dict
with Monadic Bang, I can write
initQueues = do
let getQueue = getDeviceQueue ?device
let ?graphicsQueue = !(getQueue ?graphicsQueueFamily 0)
?presentQueue = !(getQueue ?presentQueueFamily 0)
?computeQueue = !(getQueue ?computeQueueFamily 1)
pure Dict
Take this (slightly adapted) code used for the test suite of this very plugin:
settings :: MonadIO m => m Settings
settings = ... -- some long function body
initialDynFlags :: MonadIO m => m DynFlags
initialDynFlags = do
settings' <- settings
dflags <- defaultDynFlags settings' llvmConfig
pure $ dflags{generalFlags = addCompileFlags $ generalFlags dflags}
With this plugin, I can instead write
settings :: MonadIO m => m Settings
settings = ... -- some long function body
initialDynFlags :: MonadIO m => m DynFlags
initialDynFlags = do
dflags <- defaultDynFlags !settings llvmConfig
pure $ dflags{generalFlags = addCompileFlags $ generalFlags dflags}
Or, to take some more code from this plugin's implementation
do logger <- getLogger
liftIO $ logMsg logger MCInfo (UnhelpfulSpan UnhelpfulNoLocationInfo) m
Why have logger
andgetLogger
when you can instead write
do liftIO $ logMsg !getLogger MCInfo (UnhelpfulSpan UnhelpfulNoLocationInfo) m
The pattern you might have noticed here is that this plugin is convenient whenever you have a do
-block with a <-
that doesn't do pattern matching, whose bound variable is only used once, and has a short right-hand side. While that might sound like a lot of qualifiers, it does occur fairly often in practice.
Usage
To use this plugin, you have to add monadic-bang
to the build-depends
stanza in your .cabal
file. Then you can either add -fplugin=MonadicBang
to the ghc-options
stanza, or add
{-# OPTIONS_GHC -fplugin=MonadicBang #-}
to the top of the files you want to use it in.
This should also allow HLS to pick up on the plugin, as long as you use HLS 1.9.0.0 or above.
The plugin supports a couple of options, which you can provide via invocations of -fplugin-opt=MonadicBang:<option>
. The options are:
-ddump
: Print the altered AST-preserve-errors
: Keep parse errors about!
outside ofdo
in their original form, rather than a more relevant explanation. This is mainly useful if another plugin expects those errors.
Cute Things
Idiom Brackets Alternative
In some cases where idiom brackets would be ideal, !
can be a reasonable alternative. For example, compare these four options:
1. liftA2 (&&) (readIORef useMetric) (readIORef useCelsius)
2. (&&) <$> readIORef useMetric <*> readIORef useCelsius
-- hypothetical idiom brackets:
3. [| readIORef useMetric && readIORef useCelsius |]
-- Monadic Bang:
4. do pure (!(readIORef useMetric) && !(readIORef useCelsius))
while <$>
and <*>
are probably better here for prefix functions, !
plays nicer with infix operators.
If you have -XApplicativeDo
enabled, this even works with Applicative
instances.
Nested !
!
can easily be nested. E.g. you could have
do putStrLn !(readFile (!getArgs !! 1))
For how this is desugared, see Desugaring.
Using -XQualifiedDo
!
always has to be used inside a do
-block, but it can be a qualified do
-block. For example, if you use -XLinearTypes
, you could write things like
{-# LANGUAGE QualifiedDo, BlockArguments, OverloadedStrings #-}
import Prelude.Linear
import Control.Functor.Linear as Linear
import System.IO.Resource.Linear
main :: IO ()
main = run Linear.do
Linear.pure !(move Linear.<$> hClose !(hPutStrLn !(openFile "tmp" WriteMode) "foo"))
which would be desugared as
main :: IO ()
main = run Linear.do
a <- openFile "tmp" WriteMode
b <- hPutStrLn a "foo"
c <- move Linear.<$> hClose b
Linear.pure c
List comprehensions
List comprehensions are essentially just special do
-blocks, so !
can be used here as well (as well as in monad comprehensions). Example:
[ x + ![1, 2, 3] | x <- [60, 70, ![800, 900]] ]
This would be equivalent to
[ x + b | a <- [800, 900], x <- [60, 70, a], b <- [1, 2, 3]]
The reason b <- ...
is at the end here instead of the beginning is that everything that appears to the left of the |
in a list comprehension is essentially treated like the last statement of a do
-block (+ pure
).
Get Rid of <-
In principle, every instance of pattern <- action
in a do
-block could be replaced by let pattern = !action
. Should they? That's a separate question, though it could be a viable style.
The implicit parameter example in the first section is a valid use case of this.
Monadic Variants
Oftentimes, some generic function exists, but then it turns out that a monadic variant of said function would be useful as well. For example, hoogle finds at least a dozen different packages offering whenM
. With this plugin, you can instead write
main = do
when (null !getArgs) $ print usage
...
⚠️ NB: This works here since when
only needs to evaluate its condition once. If you were to try to replace e.g. one of the forms of whileM
in this manner, you would run into trouble since it's supposed to evaluate the condition again on each iteration.
Caveats
There are a few disadvantages to using this that are worth mentioning:
Since the plugin modifies the source code, the location info in error messages might look a bit strange, since it contains the desugared version. This shouldn't be an issue if you use HLS or another tool to highlight errors within your editor.
HLint currently does not work with this plugin (HLint will show you a parse error if you try to use
!
.)If there are fatal parse errors in the source code, unfortunately each
!
will also be highlighted as a parse error. This is unavoidable at the moment, since the plugin can only intercept those messages if the module is otherwise successfully parsed.Plugins like this cannot be used inside of GHCi at this time (however, you can load modules that use it into GHCi).
Arguably this makes
do
-desugaring slightly more confusing - e.g., compare the following:do put 4 put 5 >> print !get
do put 4 put 5 print !get
With the usual desugaring rules, whether you use
>>
or a new line shouldn't make a difference, but here, the first snippet will print4
, while the second snippet will print5
.Because of this, the plugin is usually best used in situations where the order in which effects happen makes no difference.
Details
While the above information should cover most use cases, there are some details that could sometimes be relevant
Desugaring
The desugaring is essentially what one would expect from comparing the motivating examples with the versions using !
.
To illustrate with a fairly extensive example:
x = g do
foo
bar <- !a + !(!b ++ !c)
baz <- case !d of
(!f -> e) -> do !g e
is desugared into
x = g do
foo
<!a> <- a
<!b> <- b
<!c> <- c
<!(!b ++ !c)> <- <!b> ++ <!c>
bar <- <!a> + <!(!b ++ !c)>
<!d> <- d
<!f> <- f
baz <- case <!d> of
(<!f> -> e) -> do
<!g> <- g
<!g> e
where <!a>
etc. are simply special variable names.
So, broadly speaking, the order in which things are bound is top-to-bottom (statement-wise), inside-out, and left-to-right.
This can be important when the order of effects matters - though as mentioned above, if order does matter, !
might not be the clearest way to express things.
!
will only bubble up to the nearest do
-block. To illustrate:
x = do when nuclearStrikeDetected $ log !launchMissiles
y = do when nuclearStrikeDetected $ do log !launchMissiles
x
will launch the missiles regardless of whether or not a strike has been detected. But it will only log the results in the case of detection. y
will only launch the missiles (and log the results) if a strike has been detected.
The desugaring:
x = do
<!launchMissiles> <- launchMissiles
when nuclearStrikeDetected $ log <!launchMissiles>
y = do
when nuclearStrikeDetected $ do
<!launchMissiles> <- launchMissiles
log <!launchMissiles>
The story for case
and if
expressions is similar, !
in the individual branches will all be executed unless the branches have their own do
-blocks.
Variable scope
A variable can be used inside a !
if
- it was bound outside the current
do
-block - or it was bound before the statement the
!
is in - or it is bound inside the
!
In other words, this is legal:
f x = do
let a = a
foo !(let b = b in x + a + b)
but this is not:
c = do
let a = a in foo !a
That's because this would be desugared as
c = do
<!a> <- a
let a = a in foo <!a>
but a
is not in scope in the second line.
Where it can be used
It can be used in any expression that is somewhere inside a do
-block. In particular, this includes for example where
-blocks in case
-expressions:
main = do
putStrLn case !getLine of
"print args" -> prettyArgs "\n"
where prettyArgs sep = intercalate sep !getArgs
"greeting" -> "hello there!"
and view patterns
do (extract !getSettings -> contents) <- readArchive
print contents
Comparison with Idris's !
-notation
The main difference is that Idris will insert a do
if there is none - e.g. this is legal in Idris:
f : IO ()
f = putStrLn !getLine
but (assuming it's at top-level) wouldn't be with this plugin; you would have to write f = do putStrLn !getLine
instead.
Some other differences:
- In Idris,
!
'd expressions cannot escape to outside of a lambda expression (it effectively inserts a newdo
at the beginning of the lambda body instead) - The same difference applies to
let
bindings that define functions.