2102 words, 11 min read

I wanted a proper "reader mode" for the articles I link to on this blog: fetch a page, strip the navigation, ads, and cookie banners, and keep only the article. In Rust, there's a crate for this called readex, which does exactly that and can even output Markdown.

My app is Elixir/Phoenix, so the obvious question was: how do I use a Rust crate from Elixir? The answer is Rustler, which lets you write NIFs (Native Implemented Functions) in Rust with safe, ergonomic bindings.

In this post, I'll walk through the whole process, from mix.exs to a green GitHub Actions pipeline, including the mistakes I made along the way.

Prerequisites: Rust and Rustler

Rustler compiles your Rust code as part of mix compile, so you need a Rust toolchain on every machine that builds the project. The easiest way is rustup:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustc --version
cargo --version

Then add Rustler to your dependencies in mix.exs:

defp deps do
  [
    # ...
    {:rustler, "~> 0.38.0", runtime: false}
  ]
end

runtime: false is the right choice here: Rustler is only needed at compile time. The compiled library is loaded by your own module, not by the Rustler application.

mix deps.get

Scaffolding the NIF with mix rustler.new

Rustler ships with a generator that creates the Cargo project for you:

mix rustler.new

It asks for the name of the Elixir module that will host the NIF and the name of the crate. I used YellowduckBe.ArticleExtractor.Native and article_extractor.

This generates the following:

native/article_extractor/
├── Cargo.toml
├── README.md
└── src/
    └── lib.rs

The generated Cargo.toml is where we add the readex crate:

[package]
name = "article_extractor"
version = "0.1.0"
authors = []
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
rustler = "0.39.0"
readex = "0.19"

crate-type = ["cdylib"] makes Cargo produce a shared library that the BEAM can load.

You also don't want build artifacts in Git, so add this to .gitignore:

# Rust / Rustler
/target/
/native/**/target/
/priv/native/

priv/native/ is where Rustler copies the compiled library so that it ends up in your release.

Implementing extract_article in Rust

The first version of the NIF was a straightforward mapping of everything readex returns. Here's the core idea:

use readex::{extract_with, Options};
use rustler::NifStruct;

#[derive(NifStruct)]
#[module = "YellowduckBe.ArticleExtractor.Article"]
struct Article {
    title: Option<String>,
    text: String,
    html: Option<String>,
    word_count: usize,
    language: Option<String>,
    byline: Option<String>,
    excerpt: Option<String>,
    site_name: Option<String>,
    published_time: Option<String>,
}

#[rustler::nif(schedule = "DirtyCpu")]
fn extract_article(html: String, url: String) -> Result<Article, String> {
    let options = Options {
        include_html: true,
        ..Options::default()
    };

    let result = extract_with(&html, Some(&url), &options)
        .map_err(|error| format!("{error:?}"))?;

    Ok(Article {
        title: result.title,
        text: result.text,
        html: result.html,
        word_count: result.word_count,
        language: result.language,
        byline: result.byline,
        excerpt: result.excerpt,
        site_name: result.site_name,
        published_time: result.published_time,
    })
}

rustler::init!("Elixir.YellowduckBe.ArticleExtractor.Native");

A few details are worth noting:

  • #[derive(NifStruct)] with #[module = "..."] converts the Rust struct into an Elixir struct of that module. No manual encoding or decoding is needed.
  • Option<String> becomes either a string or nil on the Elixir side.
  • Result<Article, String> becomes {:ok, %Article{}} or {:error, "reason"}, so it works with with and case as you'd expect.
  • schedule = "DirtyCpu" is important. Parsing and scoring an HTML document can take a while. A normal NIF that runs for more than about a millisecond blocks a scheduler and hurts the whole VM's latency. Marking it as a dirty CPU NIF runs it on a separate scheduler pool.
  • rustler::init! registers the module the NIF is loaded into. The Elixir. prefix is how Elixir module names are represented in Erlang.

Exposing it to Elixir

On the Elixir side, you need three small pieces. First, the struct that NifStruct maps to:

defmodule YellowduckBe.ArticleExtractor.Article do
  defstruct [
    :title,
    :text,
    :html,
    :word_count,
    :language,
    :byline,
    :excerpt,
    :site_name,
    :published_time
  ]
end

Second, the module that loads the native library and declares the stubs:

defmodule YellowduckBe.ArticleExtractor.Native do
  use Rustler,
    otp_app: :yellowduck_be,
    crate: "article_extractor"

  # Replaced by the NIF once it's loaded.
  def extract_article(_html, _url),
    do: :erlang.nif_error(:nif_not_loaded)
end

The stub is overridden when the library loads. If you ever see :nif_not_loaded, the library didn't compile or load.

And third, a small public module, so the rest of the app doesn't need to know there's a NIF involved:

defmodule YellowduckBe.ArticleExtractor do
  alias YellowduckBe.ArticleExtractor.Native

  def extract(html, url) do
    Native.extract_article(html, url)
  end
end

If you want to swap out the implementation or mock it in tests, this is the seam to do it at.

Using it from Elixir

Using it from a LiveView is a matter of fetching the HTML with Req and passing it along:

defp load(url) do
  with {:ok, %{body: html}} <- Req.get(url),
       {:ok, article} <- ArticleExtractor.extract(html, url) do
    {:ok, %{page: article}}
  end
end

Or in an iex -S mix session:

iex> {:ok, %{body: html}} = Req.get("https://example.com/some-article")
iex> {:ok, article} = YellowduckBe.ArticleExtractor.extract(html, "https://example.com/some-article")
iex> article.title
"Some article"
iex> article.word_count
1234

Passing the URL is useful: readex uses it to resolve relative links and images.

Compiling it in CI/CD

Everything worked locally, so I pushed it, and the pipeline immediately failed. The GitHub runner didn't know it had to build Rust.

The fix is to install a Rust toolchain before the Elixir build. I use dtolnay/rust-toolchain:

- name: Install Rust
  uses: dtolnay/rust-toolchain@stable

My first attempt added this to the wrong job. My workflow has two jobs that compile the app (one to run the tests, one to build and deploy), and both need Rust. I added it to one, moved it to the other, and finally had to put it in both before the pipeline went green.

The lesson: every job that runs mix compile needs the Rust toolchain. Remember this when you add a job later.

Caching the Rust build

A cold build of a Rust crate with its dependencies takes minutes, far longer than the rest of my pipeline. Swatinem/rust-cache caches the Cargo registry and the target directory, keyed on your Cargo.lock:

- name: Install Rust
  uses: dtolnay/rust-toolchain@stable

- name: Setup Rust cache
  uses: Swatinem/rust-cache@v2
  with:
    workspaces: native/article_extractor

The workspaces option matters: by default, the action looks for Cargo.toml in the repository root. Since the crate lives in native/article_extractor, I have to tell it where to look.

While I was there, I also added restore-keys to the existing caches for _build and deps. Without them, a changed mix.lock means a full cache miss. With them, the most recent cache with the same prefix is restored, and only what changed gets rebuilt:

- name: Cache compiled build
  uses: actions/cache@v4
  with:
    path: _build
    key: ${{ runner.os }}-mix-build-${{ hashFiles('**/mix.lock') }}-${{ hashFiles('**/.tool-versions') }}
    restore-keys: |
      ${{ runner.os }}-mix-build-

I also replaced the UBUNTU_VERSION variable in the cache keys with runner.os, the built-in way to key a cache by platform.

The gotcha: force a recompile so the NIF is rebuilt

This one cost me the most time. The pipeline was green, the caches worked, and then the app crashed at runtime with :nif_not_loaded.

Here's what happens:

  1. Rustler compiles the crate and copies the library into priv/native/.
  2. priv/native/ is in .gitignore, so it isn't in the checkout.
  3. _build is restored from cache, including the compiled .beam file for YellowduckBe.ArticleExtractor.Native.
  4. Mix sees that nothing changed in the Elixir sources, concludes the app is up to date, and skips compiling it. The Rust compiler never runs, so priv/native/ stays empty.

The cache gave me stale Elixir bytecode without the native library it needs. The fix is to delete the compiled output of just my app (not the dependencies, which are the expensive part) before compiling:

- name: Force recompile of the app (rebuilds the Rust NIF into priv/native)
  run: rm -rf _build/${{ env.MIX_ENV }}/lib/yellowduck_be

Now Mix has to recompile the app, which invokes the Rustler compiler. The Rust build is already cached by rust-cache, and Rustler copies the library into priv/native/. You get the caching benefits and a correct build.

Adding Markdown support

Once everything was running, I considered what I actually wanted to display. Plain text loses all the structure, and raw HTML needs sanitising. readex has extract_to_markdown, which returns the clean article as Markdown, making it easy to render with an Elixir Markdown library.

I also simplified the struct: I dropped the fields I wasn't using (canonical_url, image, categories, tags), replaced the optional html with the original_html I was handed, and added markdown:

use readex::{extract_to_markdown, extract_with, Options};
use rustler::NifStruct;

#[derive(NifStruct)]
#[module = "YellowduckBe.ArticleExtractor.Article"]
struct Article {
    title: Option<String>,
    original_html: String,
    markdown: String,
    text: String,
    word_count: usize,
    language: Option<String>,
    byline: Option<String>,
    excerpt: Option<String>,
    site_name: Option<String>,
    published_time: Option<String>,
}

#[rustler::nif(schedule = "DirtyCpu")]
fn extract_article(html: String, url: String) -> Result<Article, String> {
    let options = Options::default();

    let extracted = extract_with(&html, Some(&url), &options)
        .map_err(|error| format!("{error:?}"))?;

    let markdown = extract_to_markdown(&html, Some(&url), &options)
        .map_err(|error| format!("{error:?}"))?;

    Ok(Article {
        title: extracted.title,
        original_html: html,
        markdown,
        text: extracted.text,
        word_count: extracted.word_count,
        language: extracted.language,
        byline: extracted.byline,
        excerpt: extracted.excerpt,
        site_name: extracted.site_name,
        published_time: extracted.published_time,
    })
}

rustler::init!("Elixir.YellowduckBe.ArticleExtractor.Native");

The Elixir struct must match, since NifStruct expects the fields to match:

defmodule YellowduckBe.ArticleExtractor.Article do
  defstruct [
    :title,
    :original_html,
    :markdown,
    :text,
    :word_count,
    :language,
    :byline,
    :excerpt,
    :site_name,
    :published_time
  ]
end

In the LiveView template, I can render it with MDEx:

<div id="readability-content" class="prose max-w-none dark:prose-invert">
  {safe_html(page.markdown |> MDEx.to_html!())}
</div>

Note that this extracts the article twice (once for the metadata, once for the Markdown). For my use case, the cost is negligible, and the code stays simple. If this ever shows up in a profile, it's the first thing I'd look at.

The Rust part was the easy bit. Rustler's derive macros make the boundary between Rust and Elixir nearly invisible, and dirty schedulers keep long-running work from hurting the VM.

The real work was in the pipeline:

  • Every job that compiles the app needs a Rust toolchain.
  • Use rust-cache with workspaces pointing at your crate.
  • Add restore-keys to your Mix caches so a small change doesn't trigger a full rebuild.
  • When you cache _build, force a recompile of your own app, or the NIF never gets built into priv/native.

And the result is a dependable article extractor in about 40 lines of Rust, running inside my Phoenix app with no extra services to run.