Rust Development at Sentry
This is a document that contains a bunch of useful resources for getting started with Rust and adhering to our coding principles.
Getting Started
- A quick introduction into the Syntax for first-timers: A half-hour to learn Rust
- The Rust Book, comprehensively documenting the language: The Rust Programming Language
- The Async Book
- Sentry employees: Join the
#discuss-rust
channel in Slack
Coding Principles
Iterators
Prefer explicit iterator types over impl Iterator
in stable, public interfaces of published crates.
This allows to name the type in places like associated types or globals. The type name should end with Iter
per naming convention.
In addition to the standard Iterator
trait, always consider implementing additional traits from std::iter
:
FusedIterator
in all cases unless there is a strong reason not toDoubleEndedIterator
if reverse iteration is possibleExactSizeIterator
if the size is known beforehand
If it is exceptionally hard to write a custom iterator, then it can also be a private newtype around a boxed iterator:
pub struct FooIter(Box<dyn Iterator<Item = Foo>>);
impl Iterator for FooIter {
type Item = Foo;
fn next(&mut self) -> Option<Self::Item> {
self.0.next()
}
}
Async Traits
In traits you can not yet use async fn
(see this blog post).
In this case, functions should return -> Pin<Box<dyn Future<Output = ...> + Send>>
:
trait Database {
fn get_user(&self) -> Pin<Box<dyn Future<Output = User> + Send + '_>>;
}
impl Database for MyDB {
fn get_user(&self) -> Pin<Box<dyn Future<Output = User> + Send + '_>> {
Box::pin(async {
// ...
})
}
}
Note that the returned future type is Send
, to ensure that it can run on a multi-threaded runtime.
This corresponds to what the async-trait crate does.
Avoid .unwrap()
This may seem obvious, but almost always avoid the use of .unwrap()
. Even if a piece of code is guaranteed not to panic in its current form (because some precondition is met), it might be reused or refactored in a future situation where the precondition does not hold anymore.
Instead, refactor your code and function signatures to use match
, etc.
Use .get()
Instead of Slice Syntax
Slice syntax (&foo[a..b]
) will panic if the indices are out of bounds or out of order. Especially when dealing with untrusted input, it is better to use .get(a..b)
and an if let Some(...) =
expression.
Checked Math
Arithmetic under/overflows will panic in debug builds, but will lead to wrong results or panics in other parts of the code (like slice syntax mentioned above) in release builds.
It is a good idea to always use checked math, such as checked_sub
or saturating_sub
. The saturating
variants will be capped at the appropriate MIN/MAX
of the underlying data type.
Field visibility
By default, fields of structs (including tuple-structs) should be fully private. The only exception to this are:
- Newtypes (1-tuple structs) where direct access to the inner type is desired. This is to annotate semantics of a type where accessing the inner type is required.
- Plain data types with a very stable signature. For example, schema definitions such as the Sentry
Event
protocol.
Mixed visibility is never allowed, not even between private and pub(crate)
or pub(super)
. Instead, provide accessors:
foo()
,foo_mut()
andset_foo()
for exposing the valuespub(crate)
orpub(super)
accessor functions in corner cases- A
Default
implementation and builder when incremental construction is required
Style Guidelines
In general, we follow the official Rust Style Guidelines.
Import Order
Imports must be declared before any other code in the file, and can only be preceded by module doc comments and module attributes (#![...]
). We bundle imports in groups based on their origin, each separated by an empty line. The order of imports is:
- Rust standard library, comprising
std
,core
andalloc
- External & workspace dependencies, including
pub use
- Crate modules, comprising
self
,super
, andcrate
This is equivalent to the following rustfmt
configuration, which requires nightly:
imports_granularity = "Module"
group_imports = "StdExternalCrate" # nightly only
Example:
use std::borrow::Cow;
use std::collections::HashMap;
use std::io::{self, Seek, Write};
use fnv::{FnvHashMap, FnvHashSet};
use num::FromPrimitive;
use symbolic_common::{Arch, DebugId, Language};
use symbolic_debuginfo::{DebugSession, Function, LineInfo};
use crate::error::{SymCacheError, SymCacheErrorKind, ValueKind};
use crate::format;
pub use gimli::RunTimeEndian as Endian;
Declaration order
Within a file, the order of components should always be roughly the same:
- Module-level documentation
- Imports
- Public re-exports
- Modules and public modules
- Constants
- Error types
- All other functions and structs
- Unit tests
There is no hard rule on declaration order, but we suggest to place the more significant item first.
For example, for types that expose an iterator, declare the type and its impl block (including fn iter
) first, then below define the corresponding iterator.
When declaring a structure with an implementation, always ensure that the struct and its impl blocks are consecutive. Use the following order for the blocks:
- The
struct
orenum
definition impl
blockimpl
block with further constraints- Trait implementations of
std
traits - Trait implementations of other traits
- Trait implementations of own traits
Inside an impl
block, follow roughly this order (public before private):
- Associated constants
- Associated non-instance functions
- Constructors
- Getters / setters
- Anything else
Default lints
We use clippy with the clippy::all
preset for linting. See The Configuring CI section for how to invoke it.
In addition to that, every crate enable the following warnings in its top-level file after the doc block before imports (see the list of all available lints):
#![warn(missing_docs)]
#![warn(missing_debug_implementations)]
Note that we do not enable unsafe_code
, since that should already stand out in code review.
There are a few legitimate cases for unsafe code, and the unsafe
keyword alone already marks them sufficiently.
Naming
We are strictly following the Rust Naming Conventions. The entire conventions apply, although here are a few highlighted sections:
- Avoid redundant prefixes
- Getter and setter methods (
foo
instead ofget_foo
) - Conversions (
as_foo
vsto_foo
vsinto_foo
) - Iterators
- Constructors (Structs generally have
fn new(...) -> Self
, which never returns Result)
Writing Doc Comments
We follow conventions from RFC 505 and RFC 1574 for writing doc comments. See the full conventions text. Particularly, this includes:
- A single-line short summary sentence written in American English using third-person.
- Use the default headers where applicable, but avoid custom sections outside of module-level docs.
- Link between types and methods where possible, especially within the crate.
- Write doc tests
Writing Doc Tests
For crate-public utilities and SDKs, prefer writing at least one doctest for the critical path of your methods or structs. Of course, this requires the interface to be public. This should even take precedence over an equal unit test, since it both tests and documents the API.
To format code in doc comments, you can temporarily configure rustfmt to do so. We might add this to our default configuration at some point:
format_code_in_doc_comments = true
Writing Tests
All test functions should contain the function name + a simple condition in their name. The test name should be as concise as possible and avoid redundant words (such as "should", "that"). Examples:
tests::parse_empty
tests::parse_null
Place unit tests in a tests
submodule annotated with #[cfg(test)]
. This way, imports and helper functions for tests are only compiled before running tests. Depending on the editor, rust-analyzer
will auto-expand tmod
to an appropriate snippet.
fn foo() {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn foo_works() { .. }
}
Integration tests should go into the tests/
folder, ideally into a file based on the functionality they test. Such tests can only use the public interface.
See this blog post for tips on how to structure integration tests.
For libraries, consider providing examples in examples/
.
Development Environment
We use VSCode for development. Each repository contains settings files configuring code style, linters, and useful features. When opening the project for the first time, make sure to "Install the Recommended Extensions", as they will allow editor assist during coding. The probably most up-to-date example can always be found in the Relay repository:
- Rust Analyzer: A fast and feature-packed language server integration.
- CodeLLDB: Debugger based on LLDB, required to debug with the Rust Analyzer extension
- Better TOML and Crates: Support for
Cargo.toml
files and bumping dependency versions
Recommended Rust Analyzer settings in your project's .vscode/settings.json
:
{
// Enable all features
"rust-analyzer.cargo.features": "all",
// If you don't like inline type annotations, this disables it unless Ctrl + Alt is pressed.
"editor.inlayHints.enabled": "offUnlessPressed",
// Import rules
"rust-analyzer.imports.granularity.group": "module",
"rust-analyzer.imports.prefix": "crate"
}
For testing, we often use the snapshot library [insta](https://github.com/mitsuhiko/insta)
. By default, when running tests with Cargo, insta will compare snapshots and pretty-print diffs on standard out. However, to get most out of insta, install cargo-insta
and use the cargo command instead:
# Install insta. This will upgrade outdated versions.
$ cargo install cargo-insta
# To review snapshot diffs interactively:
$ cargo insta review --all
# To run all tests skipping failures:
$ cargo insta test --review
# To quickly reject all pending diffs:
$ cargo insta reject --all
Makefiles
We use Makefiles
to collect the most standard actions. A full Makefile for a workspace comprising multiple crates should roughly look like this (can be simplified):
all: check test
.PHONY: all
check: style lint
.PHONY: check
test: test-default test-all
.PHONY: test
test-default:
cargo test --all
.PHONY: test-default
test-all:
cargo test --all --all-features
.PHONY: test-all
style:
@rustup component add rustfmt --toolchain stable 2> /dev/null
cargo +stable fmt --all -- --check
.PHONY: style
lint:
@rustup component add clippy --toolchain stable 2> /dev/null
cargo +stable clippy --all-features --all --tests --examples -- -D clippy::all
.PHONY: lint
format:
@rustup component add rustfmt --toolchain stable 2> /dev/null
cargo +stable fmt --all
.PHONY: format
doc:
cargo doc --workspace --all-features --no-deps
.PHONY: doc
Cargo Version Numbers
Cargo follows Semantic Versioning, which uses X.Y.Z which are respectively called major, minor and patch version numbers. There are two major cases:
After 1.0
Once a 1.0 release is reached following SemVer is easy:
- Breaking changes require bumping the major version.
- New features bump the minor version.
- Bugfix-only releases bump the patch version.
Before 1.0
Before a 1.0 release is reached anything goes according to the spec. However Cargo has strictly defined update rules for caret requirements (caret requirements are the default if plain version numbers are used). So in order to make cargo update
behave well we adopt the following:
- Breaking changes require bumping the minor (Y) version.
- New features bump the patch (Z) version.
- Bugfix-only releases bump the patch (Z) version.
Further Reading
- The Little Book of Rust Macros
- Rust Nomicon: The Dark Arts of Unsafe Rust
- Rust by Example: A comprehensive list of examples
- Rust for Rustaceans: Idiomatic Programming for Experienced Developers
- Rust in Action: Systems programming concepts and techniques
- Zero To Production In Rust: An introduction to backend development
- Rust Atomics and Locks