Skip to content

Formatting: rustfmt, and the formatter your IDE swaps in behind it

Level: 101 → 201 · for newcomers

One line: Whitespace is inert to the compiler and load-bearing to everyone else, so the sane move is to hand the decision to a program — and the trap is that RustRover runs rustfmt on a whole file but its own built-in formatter on a selection, so the same keystroke formats the same code two different ways depending on what you had highlighted.

This page starts from eight lines somebody actually typed, with two spaces in the wrong places. Nothing about them is wrong, and that is the point: the compiler will never tell you, your reviewer will, and the only cheap way out of that argument is to stop having it.


The compiler genuinely does not care

Here is the file as it was written. Note the missing space after x, and the stray one before the closing paren.

formatting_before.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

fn main() {
    let x= 10;
    println!("{}", x);

    let x = 12;
    let y = &x;
    println!("{}", y +1 );
}

Verified output of formatting_before.rs — regenerated by tools/run_examples.py, never hand-typed.

10
13

And here is the same program after rustfmt:

formatting_after.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

fn main() {
    let x = 10;
    println!("{}", x);

    let x = 12;
    let y = &x;
    println!("{}", y + 1);
}

Verified output of formatting_after.rs — regenerated by tools/run_examples.py, never hand-typed.

10
13

Two files, different bytes on disk, identical output. That is the claim this page rests on, and it is checked on every commit rather than asserted — CI compiles both and diffs them against those recorded keys.

(The program itself is doing two things worth their own pages: let x = 12; is shadowing, a second variable reusing the name rather than an assignment to the first, and let y = &x; is a borrow. y + 1 then works without a * because std implements Add<i32> for &i32.)

What rustfmt actually changed

 fn main() {
-    let x= 10;
+    let x = 10;
     println!("{}", x);
 
     let x = 12;
     let y = &x;
-    println!("{}", y +1 );
+    println!("{}", y + 1);
 }

Two lines. One byte net. That is the entire blast radius on this file, and it is a fair sample of what the tool does day to day: spacing around binary operators and =, indentation, trailing commas, line breaks in long call chains, and the order of nothing at all — rustfmt never reorders your code, never renames anything, and never touches a comment's contents.

The reason to run it is not that its choices are better than yours. It is that they are somebody else's, permanently, so the choice stops being available to argue about.

In RustRover: which formatter just ran?

This is the part worth actually knowing, because it is invisible until it bites.

rustfmt can only format whole files — it has no "format this fragment" mode. So an IDE that wants to reformat your selection cannot use it, and RustRover falls back to its own built-in Rust formatter without saying so. Since RustRover 2024.3, rustfmt is the default for whole files, which means both formatters are live in your editor at once:

What you reformat Which formatter runs
the whole file — Cmd+Alt+L with nothing selected rustfmt
a selection the built-in IDE formatter
a group of files, or a directory the built-in IDE formatter
Code ‣ Reformat Cargo Project with Rustfmt rustfmt

So "I reformatted and CI still says the file is unformatted" has an ordinary explanation: you had something selected.

The switch lives at Settings (Cmd+,) ‣ Rust ‣ RustfmtRust is a top-level node in RustRover's settings tree, first in the list above Appearance & Behavior, not a child of Languages & Frameworks. (In IntelliJ IDEA with the Rust plugin it is under Languages & Frameworks; RustRover promotes it, reasonably, since Rust is the whole product.) The checkbox is Use Rustfmt instead of the built-in formatter, and it is ticked out of the box on 2024.3 and later — the same page carries the extra arguments, the toolchain Channel, and a Configure actions on save… link. RustRover states the split right underneath the checkbox, which is the only place it is ever said out loud: "Rustfmt will only be used to format whole files. For code fragments, the IDE will switch to the built-in formatter."

Three ways to make it automatic, in increasing order of how much you trust it:

  • On demandCmd+Alt+Shift+L opens the Reformat File dialog, whose three scopes are Only changes uncommitted to VCS, Selected text, and Whole file. The first two are fragments, so they are not rustfmt's to format; Whole file is the one that is. The action is also findable by name (Cmd+Shift+AReformat File with Rustfmt).
  • On save — Settings ‣ Rust ‣ Rustfmt ‣ Configure actions on save…, which jumps to Tools ‣ Actions on Save; tick Reformat code there. Leave its scope dropdown on Whole file. The other choice is Changed lines, and picking it asks for a fragment format — which rustfmt cannot do, so every save would quietly go through the built-in formatter instead. It is the same rule as the table above, wearing a different hat.
  • On commit — Commit tool window (Cmd+0) ‣ Show Commit Options ‣ Commit Checks ‣ Run rustfmt. Because rustfmt only does whole files, this reformats the entire file containing your change, so expect diffs beyond the lines you touched the first time you enable it on an old codebase.

From the terminal, which is what CI uses

cargo fmt

And the form that writes nothing and fails instead — the one that belongs in a pipeline:

cargo fmt -- --check

--check exits non-zero and prints the diff it would have applied. Putting that in CI is what turns a style preference into a fact about the repository; without it, formatting is advisory and the IDE settings of whoever last touched a file decide what it looks like.

Configuring it: fewer knobs than you want, on purpose

Drop a rustfmt.toml (or .rustfmt.toml) at the crate root and every entry point honours it — cargo fmt, the IDE, CI — because they all shell out to the same binary.

max_width = 100

Most of the interesting options are nightly-only and need --unstable-features, which is deliberate: a formatter with a hundred stable knobs is a formatter you can still argue about. The escape hatch for the one place where the machine's output really is worse than yours — a hand-aligned matrix, a table of constants — is per-item rather than global:

#[rustfmt::skip]
const WEIGHTS: [[i32; 3]; 3] = [
    [1, 0, 0],
    [0, 1, 0],
    [0, 0, 1],
];

Where rustfmt stops

Two boundaries, and the second one is the subject of the practice below.

It needs to parse the file. A file with a syntax error is not formatted at all — rustfmt will not guess. So "reformat did nothing" is sometimes the fastest syntax-error detector you have.

It stops at the quote. The contents of a string literal are data, and no formatter will touch them. That is correct behaviour and it is also the one hole in "formatting is cosmetic", because a raw string holding YAML, SQL, Python, or a Markdown block is whitespace-sensitive — and the tool that normally protects you from whitespace edits is, by design, silent there.

If the Rustfmt option is greyed out

The IDE is not finding a toolchain, and the standard advice will not help you if you installed Rust any way other than the default. Check Settings ‣ Rust ‣ Toolchain location: it must be the directory holding cargo and rustfmt, which for a Homebrew rustup is /usr/local/opt/rustup/bin, not the ~/.cargo/bin every guide names. Confirm from a shell first, so you know what to type:

which rustfmt

If that finds nothing at all, the component is simply missing:

rustup component add rustfmt

If you are coming from another language

  • Pythonblack (or ruff format) is the same idea and the same bargain: almost no knobs, so nobody argues, and --check in CI is what makes it stick. Two things differ. Rust shipped its formatter with the toolchain from the start, so there is no large body of code formatted the old way and no version-skew reformatting churn to schedule. And PyCharm has this page's exact trap — its own formatter unless you point it at black — so if you have met it there, you have met it here.
  • ABAP — Pretty Printer (Shift+F1) is the counterpart, right down to working on a whole object rather than a fragment. What changes is where the settings live: Pretty Printer's indent and keyword-case options are per developer, so two people pretty-printing the same include produce two different files and the transport carries the churn, while rustfmt.toml sits in the repository and is therefore the same for everyone by construction. And there is no abap fmt --check to put in a pipeline — the nearest thing is a linter such as abaplint, which reports rather than rewrites, so the enforcement step this page ends on has no ABAP equivalent.

Practice

The reformat that changed the program. This page claims whitespace is cosmetic, and the two examples at the top back it up. Find the counterexample: write a program that a well-meaning re-indentation genuinely breaks, and confirm that rustfmt neither causes it nor prevents it.

Before you look: predict which of the two blocks below rustfmt would rewrite. The answer is not "the badly indented one".

Solution

formatting_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata: the reformat that *did* change the program.
//!
//! `rustfmt` moved two spaces in this lesson's example and the output did not
//! budge, because whitespace outside a literal is inert. Inside a literal it is
//! data — and `rustfmt` will not touch it. So the one place re-indenting can
//! change what a program does is the one place the formatter refuses to go,
//! which means the tool that normally protects you is silent here by design.
//!
//! The counterexample below is a ballot block in a string, read by a rule that
//! cares about indentation. Line it up with the surrounding code and the
//! election empties out.

/// A ballot block exactly as it was pasted in: the header sits at column 0 and
/// its rows are indented under it.
const AS_PASTED: &str = r#"
ballots:
  5,2,0
  0,4,5
  2,5,4
"#;

/// The same block after somebody lined it up with the code around it. Every
/// line gained four spaces. `rustfmt` did not do this, and will not undo it.
const TIDIED: &str = r#"
    ballots:
      5,2,0
      0,4,5
      2,5,4
"#;

/// The smallest indentation-sensitive reader there is: a line at indent 0 opens
/// a section, and any line indented under it is one of that section's rows.
/// YAML, Python and Markdown all decide structure this same way.
fn read(block: &str) -> Vec<(&str, Vec<&str>)> {
    let mut sections: Vec<(&str, Vec<&str>)> = Vec::new();
    for line in block.lines() {
        if line.trim().is_empty() {
            continue;
        }
        let indent = line.len() - line.trim_start().len();
        if indent == 0 {
            sections.push((line.trim_end(), Vec::new()));
        } else if let Some(open) = sections.last_mut() {
            open.1.push(line.trim());
        }
    }
    sections
}

fn ballots(block: &str) -> usize {
    read(block).iter().map(|(_, rows)| rows.len()).sum()
}

fn describe(label: &str, block: &str) {
    let sections = read(block);
    println!("{label}");
    println!("  bytes in the literal: {}", block.len());
    println!("  sections found:       {}", sections.len());
    for (header, rows) in &sections {
        println!("    {header} -> {} rows", rows.len());
    }
    println!("  ballots counted:      {}", ballots(block));
    println!();
}

fn main() {
    describe("as pasted", AS_PASTED);
    describe("tidied to line up with the surrounding code", TIDIED);

    println!(
        "same election, {} ballots vs {}",
        ballots(AS_PASTED),
        ballots(TIDIED)
    );
    println!("rustfmt changed neither block: a string literal is data, not code.");
}

Verified output of formatting_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

as pasted
  bytes in the literal: 34
  sections found:       1
    ballots: -> 3 rows
  ballots counted:      3

tidied to line up with the surrounding code
  bytes in the literal: 50
  sections found:       0
  ballots counted:      0

same election, 3 ballots vs 0
rustfmt changed neither block: a string literal is data, not code.

rustfmt rewrites neither block — running it over this file produces a byte-identical result — and the election still empties out, because the four spaces are inside the quotes where the formatter does not go. This is why indoc! exists, and why an embedded YAML or SQL block is worth pulling out into its own file the moment it grows: a fixture in a .yaml file cannot be silently re-indented by somebody tidying up the Rust around it.

Sources

Po polsku

Formatowanie (formatting) w Ruscie nie jest kwestią gustu, tylko delegacji: rustfmt podejmuje decyzje za cały zespół po to, żeby przestały być tematem sporu na przeglądzie kodu. Dla kompilatora spacje i wcięcia są całkowicie obojętne — dwa pliki z góry tej strony różnią się bajtami na dysku i dają identyczne wyjście. Warto od razu rozdzielić nazwy: rustfmt to program, cargo fmt to opakowanie uruchamiające go na całym crate, a cargo fmt -- --check niczego nie zapisuje, tylko kończy się błędem i wypisuje diff, który by nałożył. Dopiero ta postać w CI zamienia „przyjętą konwencję” w fakt o repozytorium; bez niej o wyglądzie pliku decydują ustawienia IDE tej osoby, która edytowała go ostatnia.

Pułapka, o której jest ta strona, boli w RustRoverze i nie ma nic wspólnego z językiem interfejsu — JetBrains nie wydaje polskiej lokalizacji, więc wszystkie nazwy menu zobaczysz dokładnie w tym brzmieniu, co powyżej. Rzecz w tym, że rustfmt potrafi formatować wyłącznie całe pliki; nie ma trybu „sformatuj ten fragment”. Kiedy więc naciśniesz Cmd+Alt+L mając coś zaznaczone, IDE po cichu przełącza się na własny, wbudowany formater — ten sam skrót, inny wynik, zero komunikatu. Stąd bierze się zagadka „przecież sformatowałem, a CI dalej mówi, że plik jest niesformatowany”: coś było zaznaczone. Ta sama reguła wraca w Actions on Save, gdzie zakres Changed lines też jest fragmentem — zostaw Whole file, inaczej każdy zapis przejdzie przez wbudowany formater.

Druga granica jest ciekawsza i rzadko pada w polskich poradnikach o stylu kodu: rustfmt zatrzymuje się na cudzysłowie. Zawartość literału łańcuchowego to dane, nie kod, więc formater jej nie tknie — i właśnie dlatego wcięcie wewnątrz surowego literału (r#"…"#) potrafi zmienić działanie programu, jeśli trzymasz tam YAML, SQL albo blok Pythona. Narzędzie, które normalnie chroni cię przed skutkami przesuwania spacji, akurat w tym jednym miejscu z założenia milczy. Zadanie na końcu strony pokazuje to na wyborach, które po „wyrównaniu” bloku do otaczającego kodu liczą zero głosów.

Dwie drobne rzeczy na koniec, obie oszczędzają kwadrans: plik z błędem składni nie zostanie sformatowany w ogóle, bo rustfmt musi go najpierw sparsować — „reformat nic nie zrobił” bywa więc najszybszym detektorem literówki. A jeśli opcja Rustfmt jest wyszarzona, IDE po prostu nie widzi toolchaina: sprawdź which rustfmt w terminalu i wpisz tę ścieżkę w Settings ‣ Rust ‣ Toolchain location. Przy rustupie z Homebrew jest to /usr/local/opt/rustup/bin, a nie ~/.cargo/bin, które podaje każdy przewodnik.

Szukaj po polsku: formatowanie kodu w Ruscie · styl kodu · cargo fmt --check · rustfmt whole file only · rustrover rustfmt selection built-in formatter