Schlez
2021

Why fnm was rewritten in Rust

...or, why did I rewrite a Reason-native showcase project into Rust?Published .

This blog post was in the "drawer" for a long time, so I thought it would be nice to just publish it.


It wasn't very long ago, when I mentioned I'm rewriting fnm in Rust. At first, I was just experimenting in order to learn the language: fnm is kinda wide in its scope — works with the file system, network and user input, and I know exactly what to test. After a couple of days experimenting with Rust I had some insights, comparing it to Reason.

Reason and Rust feel very close, as if they were family. I guess that most of it comes from having ML-style type system with C-style syntax. Rust is usually treated as a lower-level language (it is), and as a very verbose one, because if you compare it to higher-level languages like Reason, you find yourself much more explicit about what you want, like passing references and static strings, etc. But seems that while you still think about memory more, Rust provides great ways to abstract knowledge and build higher-level components to your system.

The Trait System

I think one of Rust's biggest strengths is its trait system. AFAIK, it is very similar to Haskell's typeclass, Swift's protocol extensions, and they're very similar to Java's interfaces, with a unique superpower: You can implement your trait on 3rd party struct — including the standard library.

That means you can implement a custom trait on std::fs::File, or implement a standard library trait to your type, like std::default::Default or std::fmt::Debug (both can be simply derived, more on that in a bit).

Reason and OCaml do not have anything like traits. You can use functors and almost get with it, but it's not 100% there.

Seems that using traits can get you very far and provide great higher-level APIs that preserve type safety. Check out structopt, a derive macro that allows you to use the FromStr trait on any type and parse command line information from it. That means that you can declare ANY TYPE you want and use it as a CLI argument, parsing it from the user input. Do you want to use a URL? Luckily, reqwest::Url is implementing FromStr.

In fnm, we also get UserVersion which is some kind of a version requirement: it can be the system version (literal system), it can be an alias or a semantic version MAJOR.MINOR.PATCH optionally prefixed with v, where MINOR and PATCH are optional too. In fnm, we implement FromStr to UserVersion and let structopt to handle the parsing errors and print a nice message to the user.

This is one of the ways the trait system allows to build better software simpler, comparing to Cmdliner which is a great utility but was VERY hard for me to use and reason about.

Deriving

Another great Rust feature is the #[derive()] annotation. Similar to some Reason/OCaml PPX extensions, when put on top of a data structure, it can generate code for you in compile time, that will be type-checked. Rust comes with some deriving macros out of the box, like Debug (which allows you to pretty-print with the {:?} format string or dbg! macro), PartialEq and Eq for equality checks, etc.

The Rust crates embrace this type of macros, and awesome libraries use it to create higher-level abstractions:

  1. Serde allows you to derive a Serializer and Deserializer for your struct or enum, allowing you to serialize or de-serialize these data structures safely into various formats, like JSON, YAML, TOML, RON...
  2. StructOpt allows you to derive a Clap configuration from your data structures. This is amazing, because it removes so much boilerplate, and co-locate the CLI definition with the data structures.
  3. Snafu allows you to get a great developer experience for error handling, by allowing you to create error enums and define the error messages next to their declaration. It also provides some nice macros to ensure! that something works, and some extensions to data structures like Option and Result for converting them into the meaningful errors you have defined.

The Community

Last year I visited Chicago to attend ReasonConf US. I talked there to great people like Jordan, Sander, Peter and Patrick. It was tons of fun, but also made me realize something I didn't understand before.

The Reason community was divided — OCaml vs Reason, Native vs BuckleScript vs Js_of_ocaml. Being a small community, we should invest in providing the best tools and experience, and when we can't provide the best experience if we're not focused.

When writing an HTTP server in native Reason you might lack a library for New Relic, or Bugsnag, or Sentry. Stack traces are not really a thing. Using Node as a mature VM is very appealing — and if you need something that performs better — you can write it in a different language. Realistically, people ship Node apps all the time, so the performance and safety guarantees Reason provides should be enough.

In fnm, I couldn't really understand how to use the tar opam package. This is why fnm used to shell out to tar, to extract the Node versions. This would obviously fail on Windows, but fnm didn't work on Windows anyway, and I had no idea how to make it work in OCaml. In Rust, it is very easy to do conditional compilation for a specific configuration, so there are some differences in the Windows vs *nix versions of fnm now — especially regarding:

  • The installation files: Node.js is distributed in .tar.xz for *nix binaries, and .zip for Windows binaries.
  • The simple "shell inference" fnm has, to know on what shell type you're using, is using ps on *nix, and wmic on Windows. Both share the same return types so the module abstracts away the differences.

Documentation

Rust, being a newer language, has some niceities to it, like centralized documentation on https://docs.rs. It's an amazing experience, having all the documentation for every crate on one site with a familiar theme. I know both Ulrik and Patrick are very into it and I hope they'll succeed doing so.

Another cool feature is the doctests which is something that I knew from Elixir and Rust supports it natively too. When you write code in your docs, cargo test will test it. It's that easy. No more unmaintained README or other documentation. This is the future.

These are two things most languages should learn from.


Overall, feels that since the (big) Rust community is focused around the native toolchains (and not compile-to-JS), makes it easier to build native, performant CLIs in Rust compared to Reason. I no longer have to find out environment variables to compile C libraries with specific flags, because Cargo has feature toggles and I use it instead. I build an ARM-compatible binary to distribute for those running fnm in their Raspberry Pi's. We now fully support Windows, so no more gatekeeping for people with different OS choices.

All without pounding my head against the wall.

Except from the build time, which is much faster in Reason.

Read more