Skip to content

Instantly share code, notes, and snippets.

@notriddle
Last active June 28, 2022 06:45
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save notriddle/8d7ac70c2de87139398df9b19fe5ad0e to your computer and use it in GitHub Desktop.
Save notriddle/8d7ac70c2de87139398df9b19fe5ad0e to your computer and use it in GitHub Desktop.
Language package managers: Elixir's mix and Rust's cargo
Action Mix Cargo
Create a project mix new [--app APP] PATH cargo new [--bin] PATH
Run your project iex -S mix / mix run --no-halt / mix phoenix.server cargo run
Get help mix help cmd cargo [cmd] --help
Build configuration mix.exs Cargo.toml
Project commands mix COMMAND N/A
Global add-on commands mix archive.install PACKAGE cargo install PACKAGE
Integrating with other languages Compilers, plus a special case for Erlang's rebar build.rs

Create a project

With Cargo / Rust

When you create a Cargo project, the most important switch you can flip is --bin. If you pass it, Cargo will generate a "hello world" example with a main function and set up to generate an executable. Otherwise, it generates a template for a library.

You can have a single Cargo project with a binary and a library (though Cargo doesn't provide an easy way to generate a template for that). If you do this, then Cargo will compile your library, then compile the binary with the library linked to it. The only difference between having a Cargo project with both a binary and a library in it, and two Cargo projects, is that the combo project occupies the same folder.

With Mix / Elixir

All Mix projects are "OTP applications." The Erlang docs define an OTP app like this:

In OTP, application denotes a component implementing some specific functionality, that can be started and stopped as a unit, and that can be reused in other systems.

In reality, the OTP application might not actually define any processes, so "starting it" is a no op.

But an OTP application, even with a supervision tree, may or may not be usable as a standalone program. The HTTP server used by most Elixir apps (cowboy) is an OTP application. Other OTP apps can specify that they require it to be running to work, they interact with it by sending and receiving messages to cowboy's processes, and cowboy essentially acts as the app's user interface.

Beyong that, OTP applications that are usable as standalone programs may also be usable as a library, and it's the same app either way. For example, a Mix project can depend on bors-ng and the hex.pm applications, interact with their processes from inside its own OTP app, build a release of that, and deploy the new combined application, even though both are intended to use standalone. All on one instance of the Erlang VM, without any reverse HTTP proxying.

* disclaimer: I haven't actually tried that.

Run your project

With Cargo / Rust

cargo run [args...]

This is basically always how you do it. Maybe you want to specify:

cargo run --example EXAMPLE [args...]

But still. A runnable Rust application is a UNIX/Windows executable, and that's about the long and short of it. It only gets ugly if it's a Windows service, because those are actually C libraries, but let's not worry about that here.

With Mix / Elixir

When you're debugging, you're probably going to run it like this:

iex -S mix

At least, if it's a plain app. If it's a phoenix app, you need to run this:

iex -S mix phoenix.server

Remember how I said that I could extend hex.pm by building an Elixir project that depends on it? That's not feasible if the hex.pm app grabs an HTTP port and wires it up directly to its own router. So... there are two things that Phoenix does about it.

  • By default, starting a phoenix-based OTP app does not start an HTTP server.
  • You can changed this in your production config file, if you need to, and anyone depending on you can switch it back in their own config file.

By the way, you don't need to use IEx. It's just that IEx is how you start the Erlang debugger. The equivalents are:

mix run --no-halt ([elixir expression])
mix phoenix.server

Get help

With Cargo/Rust

cargo new --help

With Mix/Elixir

mix help new

Build configuration

With Cargo/Rust

It's a TOML file:

# The top-level Cargo.toml file for servo.
# Elixir users will recognize this as similar to an "Umbrella project".
# See below for the similarities and differences in that.
[workspace]
members = [
    "ports/cef",
    "ports/geckolib",
    "ports/servo",
    "support/android/build-apk",
]

[profile.dev]
codegen-units = 4

[profile.release]
opt-level = 3
# Uncomment to profile on Linux:
# debug = true
# lto = false

With Mix/Elixir

Mix configuration is an elixir module, and can contain any Elixir code you want (it runs the code to load it):

# The top-level config file for bors-ng.
# At the time I wrote this, bors-ng was also an umbrella project, though it isn't any more.
defmodule BorsNG.Mixfile do
  use Mix.Project

  def project do
    [ name: "Bors-NG",
      apps_path: "apps",
      build_embedded: Mix.env == :prod,
      start_permanent: Mix.env == :prod,
      deps: deps(),
      source_url: "https://github.com/bors-ng/bors-ng",
      homepage_url: "https://bors-ng.github.io/",
      docs: [
        main: "hacking",
        extras: [ "HACKING.md", "CONTRIBUTING.md", "README.md" ] ],
      dialyzer: [
        flags: [
          "-Wno_unused",
          "-Werror_handling",
          "-Wrace_conditions" ] ] ]
  end

  defp deps do
    [ {:dogma, "~> 0.1", only: [ :dev ], runtime: false},
      {:dialyxir, "~> 0.4", only: [ :dev ], runtime: false},
      {:distillery, "~> 1.0", runtime: false},
      {:edeliver, "~> 1.4.0", runtime: false},
      {:ex_doc, "~> 0.14", only: :dev} ]
  end
end

A popular joke, particularly floating around Ruby vs. Java threads but equally applicable here, is that if the build definition is not written in the language itself then the authors must hate the language. This is a bit of a gross oversimplification, for a couple of reasons:

  • The fact that Rust is slow to compile, which makes writing a data structure like this in the language impractical, isn't so much a bug in the language as it is a trade-off.
  • Writing software that re-writes a mix.exs file is a halting-problem hazard; it's impossible in the fully general case. Software like Dependabot can only support a non-Turing-complete subset of the language (so why bother using the actual language then?)

Add-on commands

With Cargo/Rust

Like Git, if Cargo is passed a command COMMAND that it doesn't recognize, it will shell out to cargo-COMMAND. Cargo add-ons, then, are just executables with names that start with cargo-.

With Mix/Elixir

Mix can be extended with an Elixir archive. It is a ZIP of compiled BEAM code, which Mix will load and call into when the right command is run.

This means that Mix addons must be written in Elixir, Erlang, or something else that can talk to them. In exchange, Mix addons have access to all the internal tooling that Mix already has.

Here's the in-depth on how to write Mix Tasks.

Project commands

With Cargo/Rust

Cargo doesn't seem to support "project tasks". A project task is a way for a project (or its dependencies) to add commands to the language package manager. For example, if Cargo supported project tasks, then if you ran cargo diesel-migrate in a project that depended on the Diesel ORM, it would perform the migration. Right now, you have to run cargo install diesel_cli, installing a diesel CLI globally, to do that.

You can sort-of fake it with examples,

cargo run --example my_command

Unfortunately, this doesn't carry over from dependencies, and this is using a feature in a way it wasn't intended to be used.

With Mix/Elixir

Projects can declare their own commands in their mix.exs file.

The simple way is to bake it directly into mix.exs itself:

defp aliases do
  [
    my_task: [ &my_task/1],
  ]
end
defp my_task(_) do
  IO.puts "Hello world!"
end

You can also define a Mix Task module, which hooks you into mix help and separates your code from your configuration.

Integrating with other languages

With Mix/Elixir

When mix downloads an archive to build, it checks for a mix.exs or a rebar.config. If there's a rebar.config, it shells out to rebar. Otherwise, it loads the mixfile and works with its configuration.

If it's not one of those things, though, you'll want to write a mix.exs that specifies a compiler for the source files. For example, this is how you hook up "Rustler" to build an Rust NIF to be used from Elixir (the Rustler compiler shells out to Cargo, of course):

compilers: [:rustler] ++ Mix.compilers

So while mix can suck in a typical Erlang library and have it just work, hex.pm still has a bunch of packages that exist only to wrap a corresponding C library.

With Cargo/Rust

If you specify a build.rs in your Cargo.toml file, then Cargo will compile it, then shell out to it, before building the rest of your Rust project. build.rs can have its own dependencies, so it can use canned build scripts like the cmake and gcc packages.

Ideally, Cargo would be able to pull C dependencies and build them like Mix does Erlang dependencies with Rebar, but CMake is probably the most popular C build system that runs on Windows without a UNIX emulation layer and runs on platforms other than Windows. And CMake doesn't have an accompanying package repository.

So Cargo has packages like mozjs that do nothing but shim C libaries and their build systems.

Conditional dependencies

With Cargo/Rust

You can use most of the same options as conditional compilation in Rust itself, using entries under the target key.

[target.'cfg(windows)'.dependencies]
winhttp = "0.4.0"

[target.'cfg(unix)'.dependencies]
openssl = "1.0.1"

[target.'cfg(target_arch = "x86")'.dependencies]
native = { path = "native/i686" }

[target.'cfg(target_arch = "x86_64")'.dependencies]
native = { path = "native/x86_64" }

There are also dependencies given to certain specific crates, such as build-dependencies (used for build.rs) and dev-dependencies (used for tests).

[dev-dependencies]
tempdir = "0.3"

[build-dependencies]
cc = "1.0.3"

With Mix/Elixir

Mix has special syntax for build environment-specific dependencies (much like Cargo, though "dev dependencies" in Cargo are closer to "test dependencies" in Mix, while "dev dependencies" in Mix are more like target.'cfg(debug_assertions)'.dependencies in Rust):

def deps do
  [{:credo, "~> 1.0", only: [:dev, :test]}]
end

It can also add dependencies with regular conditionals:

def deps do
  []
  ++ (if :os.type() == :win32, do: [{:winapi, "~> 1.0"}], else: [])
end

Version specifiers

In Mix/Elixir

Elixir Version specifiers define the following operations in version requirements, based exactly on SemVer 2.0.

Requirement Description
1.0.0 Equivalent to == 1.0.0
== 1.0.0 Must install version 1.0.0 exactly
>= 1.0.0 Install a version higher than or equal to 1.0.0, with no regard to breaking changes
~> 1.0.0 >= 1.0.0 and < 1.1.0

In Cargo/Rust

Rust's semver package is also based on semver 2.0, but uses a different notation than Elixir for requirements.

Requirement Description
1.0.0 Equivalent to ^1.0.0
=1.0.0 Must install version 1.0.0 exactly
>=1.0.0 Install a version higher than or equal to 1.0.0, with no regard to breaking changes
~1.0.0 >= 1.0.0 and < 1.1.0
^1.0.0 >= 1.0.0 and < 2.0.0

Workspaces vs. Umbrella projects

In principle, both features are identical.

Cargo workspaces allow you to define a single "project" (dependency compilation and fetching is what gets shared) that produces multiple crates. In all other cases, it's as if you had uploaded all but one of your crates to crates.io and made them dependencies, except don't have to go through the upgrade dance to pull in a change.

Mix umbrella projects allow you to define a single "project" (dependency compilation and fetching is what gets shared) that produces multiple OTP applications. In all other cases, it's as if you had uploaded all but one of your applications to hex.pm and made them dependencies, except you don't have to go through the upgrade dance to pull in a change.

In practice, there is a difference because of the way an OTP application runs. Using an umbrella project in Elixir allows you to break up your project into parts that can be deployed separately. On the other hand, a non-workspace Cargo project is still allowed to produce multiple executable (which can be deployed separately), and Rust applications are often not distributed anyway, so workspaces are usually either used either entirely to keep circular dependencies under control or to facilitate incremental compilation.

@milmazz
Copy link

milmazz commented Apr 6, 2017

With mix new you can generate different skeletons, e.g. if you use the --sup option, mix will generate an OTP application that includes a supervision tree, with the --umbrella option you'll get an umbrella project.

Please be aware when installing Global add-on commands with mix archive.install, they are not binaries, those packages follow the Erlang Archive Format specification and have certain restrictions, those packages cannot contain any dependencies (because they can conflict with any dependency in your Mix Project) and they usually contain small projects (because when the VM starts, it loads all the Erlang archives). phoenix_new is a perfect example of an archive, because extends Mix to create Phoenix projects.

For general scripts please use mix escript.build. In Elixir is more common to use mix deps.get, to retrieve all the dependencies of a Mix Project.

BTW, please format your Elixir source code after the Elixir Style Guide.

HTH

@matklad
Copy link

matklad commented Feb 3, 2019

When you create a Cargo project, the most important switch you can flip is --bin. If you pass it, Cargo will generate a "hello world" example with a main function and set up to generate an executable. Otherwise, it generates a template for a library.

Note that we've flipped the switch here. --bin is the default, you need to pass --lib to get a library.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment