Llogiq on stuff

Rust Life Improvement

This is the companion blog post to my eponymous Rustikon talk. The video recording and slides are also available now.

As is my tradition, I started with a musical number, this time replacing the lyrics to Deep Blue Something’s “Breakfast at Tiffany”, inspired by some recent discussions I got into:

You say that Rust is like a religion
the community is toxic
and you rather stay apart.
You say that C can be coded safely
that it is just a skill issue
still I know you just don’t care.

R: And I say “what about mem’ry unsafety?”
You say “I think I read something about it
and I recall I think that hackers quite like it”
And I say “well that’s one thing you got!”

In C you are tasked with managing mem’ry
no help from the compiler
there’s so much that can go wrong?
So what now? The hackers are all over
your systems, running over
with CVEs galore.

R: And I say “what about…”

You say that Rust is a woke mind virus,
rustaceans are all queer furries
and you rather stay apart.
You say that C can be coded safely
that one just has to get gud
still I know you just don’t care.


Beta Channel

I started out the talk by encouraging people who use Rustup to try the Beta channel. Unlike stable, one can get six weeks of performance improvements and despite thirty-five point-releases since 1.0.0, most of those didn’t fix issues that many people happened upon.

Even when one wants to be sure to only update once the first point release is likely to be out, the median release appeared roughly two weeks after the point-zero one it was based on. Besides, if more people test the beta channel, its quality is also likely to improve. It’s a win-win situation.

Cargo

Cargo has a number of tricks up its sleeve that not everyone knows (so if you already do, feel free to skip ahead). E.g. there are a number of shortcuts:

$ cargo b # build
$ cargo c # check
$ cargo d # doc
$ cargo d --open # opens docs in browser
$ cargo t # test
$ cargo r # run
$ cargo rm $CRATE # remove

besides, if one has rust programs in the examples/ subfolder, one can run them using cargo r --example <name>.

I also noted that cargo can strip release binaries (but doesn’t by default), if you add the following to your project’s Cargo.toml:

[profile.release]
strip=true

Cargo: Configuration

Cargo not only looks for the Cargo.toml manifests, it also has its own project- or user-wide configuration:

  • project-wide: .cargo/config.toml
  • user-wide, UNIX-like (Linux, MacOS, etc.): ~/.cargo/config.toml
  • user-wide, Windows: %USERPROFILE%\.cargo\config.toml

The user configuration can be helpful to…

Add more shortcuts:

[alias]
c = "clippy"
do = "doc --open"
ex = "run --example"
rr = "run --release"
bl = "bless"
s = "semver-checks"

Have Rust compile code for the CPU in your computer (which lets the compiler use all its bells and whistles that normally are off limits in case you give that executable to a friend):

[build]
rustflags = ["-C", "target-cpu=native"]

Have Rust compile your code into a zero-install relocatable static binary

[build]
rustflags = ["-C", "target-feature=+crt-static"]

Use a shared target folder for all your Rust projects (This is very useful if you have multiple Rust projects with some overlap in dependencies, because build artifacts will be shared across projects, so they will only need to be compiled once, conserving both compile time & disk space):

[build]
target = "/home/<user>/.cargo/target"

Configure active lints for your project(s):

[lints.rust]
missing_docs = "deny"
unsafe_code = "forbid"

[lints.clippy]
dbg_macro = "warn"

There are sections for both Rust and Clippy. Speaking of which:

Clippy

This section has a few allow by default lints to try:

missing_panics_doc, missing_errors_doc, missing_safety_doc

If you have a function that looks like this:

pub unsafe fn unsafe_panicky_result(foo: Foo) -> Result<Bar, Error> {
    match unsafe { frobnicate(&foo) } {
        Foo::Amajig(bar) => Ok(bar),
        Foo::Fighters(_) => panic!("at the concert");
        Foo::FieFoFum => Err(Error::GiantApproaching),
    }
}`

The lints will require # Errors, # Panics and # Safety sections in the function docs, respectively:

/// # Errors
/// This function returns a `GiantApproaching` error on detecting giant noises
///
/// # Panics
/// The function might panic when called at a Foo Fighters concert
///
/// # Safety
/// Callers must uphold [`frobnicate`]'s invariants'

There’s also an unnecessary_safety_doc lint that warns on # Safety sections in docs of safe functions (which is likely a remnant of an unsafe function being made safe without removing the section from the docs, which might mislead users):

/// # Safety
///
/// This function is actually completely safe`
pub fn actually_safe_fn() { todo!() }

The multiple_crate_versions will look at your dependencies and see if you have multiple versions of a dependency there. For example, if you have the following dependencies:

  • mycrate 0.1.0
    • rand 0.9.0
    • quickcheck 1.0.0
      • rand 0.8.0

The rand crate will be compiled twice. Of course, that’s totally ok in many cases (especially if you know that your dependencies will catch up soon-ish), but if have bigger dependencies, your compile time may suffer. Worse, you may end up with incompatible types, as a type from one version of a crate isn’t necessarily compatible with the same type from another version.

So if you have long compile times, or face error messages where a type seems to be not equal to itself, this lint may help you improve things.

The non_std_lazy_statics lint will help you to update your code if you still have a dependency on lazy_static or once_cell for functionality that has been pulled into std. For example:

// old school lazy statics
lazy_static! { static ref FOO: Foo = Foo::new(); }
static BAR: once_cell::sync::Lazy<Foo> = once_cell::sync::Lazy::new(Foo::new);

// now in the standard library
static BAZ: std::sync::LazyLock<Foo> = std::sync::LazyLock::new(Foo::new);

The ref_option and ref_option_ref lints should help you avoid using references on options as function arguments. Since an Option<&T> is the same size as an &Option<T>, it’s a good idea to use the former to avoid the double reference.

fn foo(opt_bar: &Option<Bar>) { todo!() }
fn bar(foo: &Foo) -> &Option<&Bar> { todo!() }

// use instead
fn foo(opt_bar: Option<&Bar>) { todo!() }
fn bar(foo: &Foo) -> Option<&Bar> { todo!() }

The same_name_method lint helps you avoid any ambiguities with would later require a turbo fish to resolve.

struct I;
impl I {
    fn into_iter(self) -> Iter { Iter }
}
impl IntoIterator for I {
    fn into_iter(self) -> Iter { Iter }
    // ...
}

The fn_params_excessive_bools lint will warn if you use multiple bools as arguments in your methods. Those can easily be confused, leading to logic errors.

fn frobnicate(is_foo: bool, is_bar: bool) { ... }

// use types to avoid confusion
enum Fooish {
    Foo
    NotFoo
}

Clippy looks for a clippy.toml configuration file you may want to use in your project:

# for non-library or unstable API projects
avoid-breaking-exported-api = false
# let's allow even less bools
max-fn-params-bools = 2

# allow certain things in tests
# (if you activate the restriction lints)
allow-dbg-in-tests = true
allow-expect-in-tests = true
allow-indexing-slicing-in-tests = true
allow-panic-in-tests = true
allow-unwrap-in-tests = true
allow-print-in-tests = true
allow-useless-vec-in-tests = true

Cargo-Semver-Checks

If you have a library, please use cargo semver-checks before cutting a release.

$ cargo semver-checks
    Building optional v0.5.0 (current)
       Built [   1.586s] (current)
     Parsing optional v0.5.0 (current)
      Parsed [   0.004s] (current)
    Building optional v0.5.0 (baseline)
       Built [   0.306s] (baseline)
     Parsing optional v0.5.0 (baseline)
      Parsed [   0.003s] (baseline)
    Checking optional v0.5.0 -> v0.5.0 (no change)
     Checked [   0.005s] 148 checks: 148 pass, 0 skip
     Summary no semver update required
    Finished [  10.641s] optional

Your users will be glad you did.

Cargo test

First, doctests are fast now (apart from compile_fail ones), so if you avoided them to keep your turnaround time low, you may want to reconsider.

Also if you have a binary crate, you can still use #[test]' by converting your crate to a mixed crate. Put this in your Cargo.toml`:

[lib]
name = "my_lib"
path = "src/lib.rs"

[[bin]]
name = "my_bin"
path = "src/main.rs"

Now you can test all items you have in lib.rs and any and all modules reachable from there.

Insta

Insta is a crate to do snapshot tests. That means it will use the debug representation or a serialization in JSON or YAML to create a “snapshot” once, then complain if the snapshot has changed. This removes the need to come up with known good values, since your tests will create them for you.

#[test]
fn snapshot_test() {
    insta::assert_debug_snapshot!(my_function());
}

insta has a few tricks up its sleeve to deal with uncertainty arising from indeterminism. You can redact the output to e.g. mask randomly chosen IDs:

#[test]
fn redacted_snapshot_test() {
    insta::assert_json_snapshot!(
        my_function(),
        { ".id" => "[id]" }
    );
}

The replacement can also be a function. I have used this with a Mutex<HashMap<..>> in the past to replace random IDs with sequence numbers to ensure that equal IDs stay equal while ignoring their randomness.

Cargo Mutants

Mutation testing is a cool technique where you change your code to check your tests. It will apply certain changes (for example replacing a + with a - or returning a default value instead of the function result) to your code and see if tests fail. Those changes are called mutations (or sometimes mutants) and if they don’t fail any tests, they are deemed “alive”.

I wrote a bit about that technique in the past and even wrote a tool to do that as a proc macro. Unfortunately, it used specialization and as such was nightly only, so nowadays I recommend cargo-mutants. A typical run might look like this:

$ cargo mutants
Found 309 mutants to test
ok       Unmutated baseline in 3.0s build + 2.1s test
 INFO Auto-set test timeout to 20s
MISSED   src/lib.rs:1448:9: replace <impl Deserialize for Optioned<T>>::deserialize -> Result<Optioned<T>,
 D::Error> with Ok(Optioned::from_iter([Default::default()])) in 0.3s build + 2.1s test
MISSED   src/lib.rs:1425:9: replace <impl Hash for Optioned<T>>::hash with () in 0.3s build + 2.1s test
MISSED   src/lib.rs:1202:9: replace <impl OptEq for u64>::opt_eq -> bool with false in 0.3s build + 2.1s test
TIMEOUT  src/lib.rs:972:9: replace <impl From for Option<bool>>::from -> Option<bool> with Some(false) in 0.4s
 build + 20.0s test
MISSED   src/lib.rs:1139:9: replace <impl Noned for isize>::get_none -> isize with 0 in 0.4s build + 2.3s test
MISSED   src/lib.rs:1228:14: replace == with != in <impl OptEq for i64>::opt_eq in 0.3s build + 2.1s test
MISSED   src/lib.rs:1218:9: replace <impl OptEq for i16>::opt_eq -> bool with false in 0.3s build + 2.1s test
MISSED   src/lib.rs:1248:9: replace <impl OptEq for f64>::opt_eq -> bool with true in 0.4s build + 2.1s test
MISSED   src/lib.rs:1239:9: replace <impl OptEq for f32>::opt_eq -> bool with false in 0.4s build + 2.1s test
...
309 mutants tested in 9m 26s: 69 missed, 122 caught, 112 unviable, 6 timeouts

Unlike code coverage, mutation testing not only finds which code is run by your tests, but which code is actually tested against changes – at least as far as they can be automatically applied.

Also mutation testing can give you the information which tests cover what possible mutations, so you sometimes can remove some tests, making your test suite leaner and faster.

rust-analyzer

I just gave a few settings that may improve your experience:

# need to install the rust-src component with rustup
rust-analyzer.rustc.source = "discover" 
# on auto-import, prefer importing from `prelude`
rust-analyzer.imports.preferPrelude = true
# don't look at references from tests
rust-analyzer.references.excludeTests = true

cargo sweep

If you are like me, you can get a very large target/ folder.

cargo sweep will remove outdated build artifacts:

$ cargo sweep --time 14 # remove build artifacts older than 2 weeks
$ cargo sweep --installed # remove build artifacts from old rustcs

Pro Tip: Add a cronjob (for example every Friday on 10 AM):

0 10 * * fri sh -c "rustup update && cargo sweep --installed"

cargo wizard

This is a subcommand that will give you a TUI to configure your project, giving you a suitable Cargo.toml etc.

cargo pgo

Profile-guided optimization is a great technique to eke out that last bit of performance without needing any code changes. I didn’t go into detail on it because Aliaksandr Zaitsau did a whole talk on it and I wanted to avoid the duplication.

cargo component

This tool will allow you to run your code locally under a WASM runtime.

  • Run your code in wasm32-wasip1 (or later)
  • the typical subcommands (test, run, etc.) work as usual
  • can use a target runner:
[target.wasm32-wasip1]
runner = ["wasmtime", "--dir=."]

The argument is used to allow accessing the current directory (because by default the runtime will disallow all file access). You can of course also use different directories there.

bacon

compiles and runs tests on changes

great to have in a sidebar terminal

‘nuff said.

Language: Pattern matching

Rust patterns are super powerful. You can

  • destructure tuples and slices
  • match integer and char ranges
  • or-combine patterns with the pipe symbol, even within other patterns (note that the bindings need to have the same types). You can even use a pipe at the start of your pattern to get a nice vertical line in your code (see below)
  • use guard clauses within patterns (pattern if guard(pattern) => arm)
match (foo, bar) {
  (1, [a, b, ..]) => todo!(),
  (2 ..= 4, x) if predicate(x) => frobnicate(x),
  (5..8, _) => todo!(),
  _ => ()
}

if let Some(1 | 23) | None = x { todo!() }

match foo {
  | Foo::Bar
  | Foo::Baz(Baz::Blorp | Baz::Blapp)
  | Foo::Boing(_)
  | Foo::Blammo(..) => todo!(),
  _ => ()
}

matches!(foo, Foo::Bar)

Also patterns may appear in surprising places: Arguments in function signatures are patterns, too – and so are closure arguments:

fn frobnicate(Bar { baz, blorp }: Bar) {
  let closure = |Blorp(flip, flop)| blorp(flip, flop);
}

What’s more, patterns can be used in let and in plain assignments:

let (a, mut b, mut x) = (c, d, z);
let Some((e, f)) = foo else { return; };
(b, x) = (e, f);

As you can see, with plain let and assignment, you need an irrefutable pattern (that must always match by definition), otherwise you can do let-else.

Language: Annotations

use #[expect(..)] instead of #[allow(..)], because it will warn if the code in question is no longer linted (either because the code or clippy changed), so the #[allow(..)] will just linger.

#[expect(clippy::collapsible_if)
fn foo(b: bool, c: u8) [
    if b {
        if c < 25 {
            todo!();
        }
    }
}

Add #[must_use] judiciously on library APIs to help your users avoid mistakes. There’s even a pedantic clippy::must_use_candidates lint that you can auto-apply to help you do it.

You can also annotate types that should always be used when returned from functions.

#[must_use]
fn we_care_for_the_result() -> Foo { todo!() }

#[must_use]
enum MyResult<T> { Ok(T), Err(crate::Error), SomethingElse }

we_care_for_the_result(); // Err: unused_must_use
returns_my_result(); // Err: unused_must_use

Traits sometimes need special handling. Tell your users what to do:

#[diagnostic::on_unimplemented(
    message = "Don't `impl Fooable<{T}>` directly, `#[derive(Bar)]` on `{Self}` instead",
    label = "This is the {Self}"
    note = "additional context"
)]
trait Fooable<T> { .. }

Sometimes, you want internals to stay out of the compiler’s error messages:

#[diagnostic::do_not_recommend]
impl Fooable for FooInner { .. }

library: Box::leak

For &'static, once-initialized things that don’t need to be dropped

let config: &'static Configuration = Box::leak(create_config());
main_entry_point(config);

The End?

That’s all I could fit in my talk, so thanks for reading this far.