opam is a source-based package manager for OCaml. It is the de-facto standard for package management in the OCaml ecosystem. opam’s main package repository contains over 4000 individual packages, on average spanning 7 versions each.
Like many other language-specific package managers (e.g. cargo, cabal, etc.), opam performs four main tasks:
- Download the sources.
- Resolve the needed dependencies of a package.
- Provide those dependencies such that the build system can find them.
- Run the build system.
It is pretty good at this. However, there are some problems with step (4):
- The build is not properly isolated: it can (in theory) fetch arbitrary things from the network, access arbitrary files on the filesystem, and even modify other packages. This allows for irreproducible builds, and makes it easy to forget to explicitly list a system dependency if it happens to be installed on the author’s system.
- “External” (system) dependencies, such as non-OCaml binaries and libraries,
are taken from the user’s distribution repository (using the distribution’s
package manager, e.g.
apt-get
), resulting in version inconsistencies and the possibility for breakage. - The builds are not easily cached and reused, meaning you have to compile packages locally.
Also, opam’s user interface is based around imperative commands, meaning that the developer environment setup has to be a script that modifies a switch (opam’s term for an independent collection of interdependent packages), which is fragile, difficult to update, and prone to inconsistencies. While there are tools that solve some of those issues, this can still be painful.
Finally, opam is a package manager for OCaml. It can’t easily integrate with other programming languages. This is a problem for modern software stacks, which often feature multiple programming languages in a single project.
Introducing opam-nix
If you’re familiar with Nix, you might have noticed that it doesn’t have any
of the aforementioned problems. However, Nix on its own doesn’t know how to
build opam packages. The solution to this? Make a library that “translates” opam
packages into a format which Nix can understand. That’s exactly what opam-nix
is!
opam-nix
provides low-level functions which parse opam files into Nix data structures (using opam-file-format), interpret those
data structures, resolve the dependency tree (using opam itself), and turn the
dependency tree into derivations. It also has some overrides which ensure that a
lot of popular packages build and function correctly.
If that sounds scary, don’t worry, opam-nix
also provides high-level tools
which make Nixifying your OCaml projects a breeze. In this blog post, we’ll
show some examples of how to quickly Nixify your existing opam-based projects.
Whether you’re working on improving the onboarding experience on a project or are an
OCaml developer yourself, we hope you’ll find it useful.
In the following, we assume that you already have Nix installed and are using flakes. Also, the templates are suited to building opam projects.
Simple package
To get started, fetch the template provided with opam-nix
. From your project’s
root:
$ nix flake init -t github:tweag/opam-nix
$ git add flake.nix
Nix will create a flake.nix
for you. Note that you have to add it to the Git
index, otherwise Nix will not pick it up. Open it with your editor, look through
the file, and replace the throw
with your package name, as specified in the
comment:
outputs = { self, flake-utils, opam-nix, nixpkgs }@inputs:
- # Don't forget to put the package name instead of `throw':
- let package = throw "Put the package name here!";
+ let package = "my-package";
in flake-utils.lib.eachDefaultSystem (system:
Nix is famous for its “shells”: ad-hoc, on-the-fly environments, allowing
developers to quickly get started on and switch between projects. opam-nix
allows you to leverage this potential to make onboarding to your projects quick
and easy.
Now run
$ nix develop
Nix will download and lock opam-nix, nixpkgs, opam-repository, and some other dependencies, then build all the dependencies of your project. Once that’s done, it will drop you into a shell with all the dependencies available.
Unfortunately, that won’t always happen; opam-nix
can’t provide perfect
compatibility with opam. Most errors you will get are actually symptoms of
problematic packaging of your dependencies, e.g. missing a system dependency
requirement, arbitrary network access during the build, etc. The proper way to
fix such errors is to fix the packaging upstream. However, you can also just
override the dependency using the overlay
in your flake.nix
. You can read
more about overlays and package overrides if you are not familiar. For
example, you can change the commands used to build a package like this:
overlay = final: prev:
{
# Your overrides go here
+ dune = prev.dune.overrideAttrs (_: { buildPhase = "make release"; });
};
in {
legacyPackages = scope.overrideScope' overlay;
Additionally, if your project requires libraries or tools written in other
languages, you can look for them in nixpkgs or package them
with Nix (maybe using other *-nix tools). Once you have the package, you can
simply then inject it into buildInputs
of your project using overrideAttrs
inside an overlay
.
Once you get to the shell, you can use the usual tools to build your package.
Typically, that would be something like dune build
or make
. You can also use
nixpkgs phases to build and test your project using commands specified in the
opam file:
$ eval "$prePatch"
$ eval "$configurePhase"
$ eval "$buildPhase"
$ eval "$checkPhase"
Note that this approach combines the benefits of Nix (reproducibility, reliable caching, isolation) for your dependencies with the benefits of your build system (fast, incremental builds, granular caching) for your project itself.
If you want to go all-in on Nix, you can build your project as a Nix derivation too. Just run:
$ nix build
This will build and check your project. You can find the build artifacts in the
result
folder.
Fully-featured development environment
Many developers are not content with simply being able to build the package, though; they also want to have modern amenities such as a language server to get interactive documentation, type information, and code navigation.
Worry not! opam-nix
can also help with that. We’ll use a different template
here, so make sure to back up any changes to flake.nix
you might have made in
the previous section.
$ mv flake.nix flake.nix.bkp
$ nix flake init -t github:tweag/opam-nix#multi-package
$ git add flake.nix
Replicate the overrides you made before, if any. You don’t need to specify
the package name here, since this template picks up all packages in your
repository. It’s also a good idea to look through flake.nix
, as it might give
you ideas for improving your development experience.
You can now use
nix develop
to get a development environment, as before.
However, now you also get ocaml-lsp-server
and ocamlformat
available. You
can add other OCaml tools to the devPackagesQuery
as well. For your editor to
pick them up, you’ll have to start it from this environment. Alternatively, since
this template provides an .envrc
, you can use direnv, which has integrations
with many popular editors.
Note that for ocaml-lsp
to work, it must be able to find type information for
your project. Typically, you can ensure this by running
$ dune build @check
If you wish, you can also build your package with:
$ nix build .#<your-package>
Diving deeper
Since opam-nix
is a Nix tool, you get a lot of advantages of Nix, like
reproducibility, easy CI with binary caching for even faster
developer onboarding, integration with NixOS to get reproducible system
deployments, or with Docker for compatibility with most of the world.
Also, because opam-nix
follows the philosophy of composing small, low-level
parts to make a bigger, user-friendly whole, you can make use of it
even if your project doesn’t fit the requirements of buildOpamProject
.
For dune-project
-based projects, you can use buildDuneProject
instead
of buildOpamProject
.
If you just have a single opam export, that’s then imported with opam import
.
This setup can be replicated with a snippet like this:
let
# This is a list of package names installed in the switch
switch = opam-nix.opamListToQuery (opam-nix.fromOPAM ./opam.export).installed;
scope = with opam-nix;
queryToScope { repos = [ opamRepository (makeOpamRepo ./.) ]; }
(switch // { my-package = "dev"; });
in scope.my-package
Or, if you want to build a Mirage unikernel, you can
do so using hillingar, an opam-nix
-
based tool.
If you want to speed up your project by avoiding Import From Derivation,
opam-nix
supports haskell.nix
-style materialization.
Also, if you’re using jupyenv’s OCaml kernel, you’re actually
using opam-nix
under the hood; this means all the tricks mentioned previously
can also work there
You can check out the documentation for all the public functions provided by
opam-nix
in the README. Let us know if you make something cool with this!
Inspirations & alternatives
opam-nix
wouldn’t be possible without opam and opam-file-format. It uses
them directly and reimplements some parts of them in Nix.
The way opam-nix
works is similar to crate2nix, cabal2nix, and
poetry2nix. Inspiration for many technical decisions was drawn from these
projects.
Finally, there are a couple of similar projects which serve a similar purpose but achieve it slightly differently.
- opam2nix is the original in this space. However, it requires committing generated files into the repository, doesn’t integrate well with Flakes, and is not as flexible.
- opam-nix-integration is quite similar to opam-nix. However, it’s not as flexible since it doesn’t allow to import opam switches or easily call multiple packages from the same workspace.
Conclusion
opam-nix
is a flexible yet user-friendly library to turn opam packages
into Nix derivations. It is already mature enough to be used by dune, ocaml-lsp,
and others, and yet there are still features and interface improvements
waiting to happen. We encourage you to try it on your project and share your
experience and feedback in the issue tracker.
About the author
Alexander is a passionate, perpetually learning software engineer and hacker. Lives and breathes FOSS. Loves functional programming and declarative approach to system configuration. He has worked as a DevOps/SRE for typeable.io and serokell.io in the past, and is currently working on improving Developer Productivity at tweag.io.
If you enjoyed this article, you might be interested in joining the Tweag team.