Experiences with support of Cabal's public sublibrary feature
This is not a very high quality post: I don’t introduce anything that hasn’t been written about before already, and I don’t feel like I looked very deeply into the subject. It’s just an experience report, but hopefully that experience is useful to someone out there.
Pre-ramble
This is a brief rundown of the state of Cabal’s “public sublibrary” feature from
the perspective of a me. I normally develop Haskell projects within a Nix shell.
The level of reproducibility is extremely attractive. I recently span out a few
libraries I’ve developed into their own open-source repositories; see the flurry
of activity (not to be mistaken for productivity; mv did most of the work) on my GitLab.
For streaming-json I think the use-case of it is for web servers which produce
large amounts of JSON. It’s structured in a way that makes it easier to do less
allocation while producing said JSON, just writing to handles directly. There
are a lot of web server libraries in the Haskell ecosystem and it’d be nice to
support as many of them as I can.
The standard way to accomplish this is to just make separate Cabal packages for each interop library; they can all live in harmony in monorepo. When you have your Git repo cloned somewhere, you ultimately have a directory structure that looks like this:
~/Code/streaming-json/.git
~/Code/streaming-json/LICENSE
~/Code/streaming-json/README
~/Code/streaming-json/streaming-json/streaming-json.cabal
~/Code/streaming-json/streaming-json/src/Streaming/JSON.hs
~/Code/streaming-json/streaming-json/src/Streaming/JSON/Lines.hs
~/Code/streaming-json/streaming-json-servant/streaming-json-servant.cabal
~/Code/streaming-json/streaming-json-servant/src/Streaming/JSON/Servant.hs
~/Code/streaming-json/streaming-json-yesod/streaming-json-yesod.cabal
~/Code/streaming-json/streaming-json-yesod/src/Streaming/JSON/Yesod.hs
...
# haha, "streaming-json/streaming-json/streaming-json"Anyway, it’s sorta crap. I don’t want to maintain 3 or more almost-identical
.cabal files, in 3 or more different Hackage packages, in 3 or more
almost-identical source trees, with the inability to see anything other than
streaming-json in any of the paths at a glance 1.
It’s a bunch of minor irritations, but I just don’t like dealing with a bunch of
minor irritations every time I switch files within a project in my editor.
In Cabal we don’t have feature flags like in Cargo. Feature flags undoubtedly do have their own potential pitfalls, discussed at some length across a few different GitHub issues, Reddit posts, and Discourse forums. Proponents such as myself are likely to think: well, Cargo does have them – and with very little actual restriction in terms of the foottrebuchets you can erect, and it seems to work quite nicely. But I could very well be blissfully ignorant of the pain points feature flags perhaps are creating for Rust/Cargo ecosystem maintainers. And definitely I am blissfully ignorant of the pain it would create for the Cabal project to actually implement.
Happily, Cabal does have another trick up its sleeve: public sublibraries. This was written about already by Kowainik back in 2019 – so how are things fairing almost 6 years later?
Recap: You can write sublibraries as ordinary library stanzas in your cabal
file, just with an additional name, like library streaming-json-servant. Then
set visibility: public within that stanza, and now you have a public sublibrary.
If it’s in your available package set, then you should be able to then use that
library from another package, like build-deps: streaming-json:streaming-json-servant.
This seems like a great way to implement optional features (with their own transitively-optional dependencies) in a library because unlike Cargo feature flags, which merely “should” be additive, Cabal sublibraries are additive 2. So that footgun is avoided completely.
Getting to a REPL from various toolchain setup options
I’ve now tested out the feature across a few different “typical” Haskell
development setups. The goal is to use a the streaming-json library and its
streaming-json-servant sublibrary from another project, fetching streaming-json
from its Git repo.
I set up a repo on my GitLab called sublib-test. Each branch has a different
setup from which I try to pull in streaming-json and its sublibrary. There’s not
any “actual” code there. (I feel no offence if readers just flick through the
branches on that repo to see what’s different in each, rather than reading on; a
repo speaks a thousand words.)
A few of the setups use Nix. You could set up a Nix project without using flakes
at all, but use Nix flakes I have. I like the ergonomics of being able to nix flake update my dependencies, although you can definitely accomplish that with
niv and its relatives too. In any case, the “real” difference between usual Nix
setups for Haskell projects is the choice of using the default Haskell infra in
Nixpkgs, or to use IOHK’s haskell.nix – in both style and substance they are
quite different. The third option is using Nix just for getting a development
shell going, which is fine, but nearly equivalent to using ghcup, except with
better isolation of different toolchains across projects.
Basic scaffold
See the link in the heading. The branches on this repo are the different options below.
Option 1: Nix flake
- Use
nix flake init -t templates#haskell-helloto get the basic scaffold - Do some very minor finagling to get the
flake.nixto support “extra deps” from outside Hackage. - Add the Git repo as a input to the
flake.nixor usefetchFromGitLab. I put it as an input on theflake.nix(withflake = false), because it’s nice to be able tonix flake updatea dependency. nix develop(I would recommend Direnv’suse_flakein an actual project. You will likely get better editor integration [such as if your dev shell provides HLS, cabal-gild, fourmolu, etc.] that way.)cabal repl- Realise that it doesn’t work and experience slight despair and confusion –
what is a package anyway, as opposed to a component, or sublibrary? Are these
distinctions the reason why sublibrary support is kinda broken and complex,
because you need different code to handle all the different sorts of
“components” that Cabal packages contain? Can you convince both Nix and Cabal
of the existence of the sublibrary somehow? The answer to these questions are
I don’t know; nor do I know if they are the right questions in the first
place.
ghc-pkg listseems to pick up the sublibrary.
Option 2: haskell-flake based on nix flake init -t templates#haskell-flake
Basically the same as plain but in a nice, declarative wrapper around plain Nix, which I’m definitely coming around to. My experience with nice declarative abstraction in Nix has sometimes involved having to completely break out of the nice declarative abstraction in order to do something though, so I’m slightly weary of it. Sublibrary support isn’t any different here from in the plain option.
Option 3: haskell-nix based on nix flake init -t templates#haskell-nix
This uses haskell.nix to parse the cabal.project file (see below). I almost gave
up after 4 hours of being stuck on compiling
generics-sop-lib-generics-sop-x86_64-w64-mingw32 until I realised it’s
configured in nix/hix.nix where cross platform compilation support is configured
(which is pretty cool to have! But not very useful for my development
environment).
An initial cabal repl actually works within this environment – but only after
cabal-install itself fetched streaming-json. For actual builds (as opposed to
generating a dev shell) haskell.nix does actually fetch the repository, and it’s
able to build and utilise the sublibrary. Huzzah – there’s at least one way for
a Haskell Nix project to use Cabal sublibraries. I’m not sure how I feel about
it overall, as it’s a quite complex abstraction, with its own nixpkgs even.
Option 4: ghcup + Cabal setup
This is what newcomers to Haskell would likely be using. The experience here was actually very straightforward.
- Use
ghcupto set up GHC andcabal-install. - Add a
cabal.projectwhich has the Git repo forstreaming-jsonat the right revision. cabal updatecabal repl>>> import Streaming.JSON.Servant- yay!
Option 5: Stack
This was also very straightforward and a likely option that Haskell newcomers would use. Stack makes a bit more effort to have helpful (and searchable) error messages which was nice.
- Use
ghcupto acquire Stack. - Add a
stack.yaml stack repl>>> import Streaming.JSON.Servant- yay!
Documentation
This is where sublibrary support still sucks: Haddock doesn’t generate
documentation for sublibraries by default, but you can get some documentation
out of it, by explicitly targetting all the components you want docs for, like
cabal haddock streaming-json streaming-json:streaming-json-servant. The
sublibrary documentation will be placed under an enigmatically-named directory
l/sublibrary-name. I don’t think Hackage will do this for you at the moment
though, so any sublibraries actually on there won’t get rendered docs AFAIK.
Conclusion
So there you have it. Support for public sublibraries is good for cabal-install,
Stack, and Haskell.nix users. It’s sorta shit in “plain” Nix. This post was
generated using the help of a keyboard and my tired fingers. Hopefully it is
helpful to someone out there.
my use of
(projectile-find-file)is utterly enfeebled by this directory structure :D↩︎to the extent that Haskell themselves modules are additive, anyway. You could do slightly evil fake conditional compilation with an argumentless open type family
type family Evil :: Symbolwith no instance, but lots of definitions likex :: (Evil ~ "yes") => (); y :: (Evil ~ "no") => ()~, effectively locking the API to only what the ultimate choice of theEviltype is.↩︎