# indoc un-indents multiline string literals at compile time

> indoc is a procedural macro crate for Rust that takes a multiline string literal and removes the common leading indentation at compile time, so embedded code, help text and expected outputs can be written flush left in source. It also ships six formatting macro variants and a companion unindent crate for strings that are only known at run time.

**dtolnay/indoc** — Indented document literals for Rust

- Repository: https://github.com/dtolnay/indoc
- Stars: 791 · Forks: 25
- Language: Rust
- License: Apache-2.0
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/dtolnay-indoc

## A proc macro that removes the common indentation

indoc provides a procedural macro for indented string literals. The indoc! macro takes a multiline string literal and un-indents it at compile time so that the leftmost non-space character is in the first column.

The problem it solves is small and permanent. Write a Rust string literal containing a code snippet, and the literal has to be indented to match the surrounding code, which means the string carries that indentation as part of its value. Compare it against an expected string, and the two do not match unless you strip the whitespace by hand at every level of nesting.

indoc moves that work to compile time. The macro inspects the literal, computes the indentation to remove, and hands the runtime a string that starts in the first column. There is no runtime component, and the crate's own categories list includes no-std and no-std::no-alloc, so it fits into constrained builds.

The project is by David Tolnay, the Cargo.toml names it a proc-macro package with the description Indented document literals, and it is published on crates.io as indoc with documentation on docs.rs. The version in the repository is 2.0.8, the edition is 2021, and the declared rust-version is 1.71.

The audience is exactly the set of Rust developers who write expected-output tests, embed usage text, or paste sample code into a doc comment, which is a larger set than it sounds.

## Adding the dependency and a first use

The dependency is one line in Cargo.toml:

```toml
[dependencies]
indoc = "2"
```

And the usage is a macro call around a literal:

```rust
use indoc::indoc;

fn main() {
    let testing = indoc! {"
        def hello():
            print('Hello, world!')

        hello()
    "};
    let expected = "def hello():\n    print('Hello, world!')\n\nhello()\n";
    assert_eq!(testing, expected);
}
```

The literal inside the macro is written flush in the source with the usual indentation, and the expected value on the next line is the flattened version. The assert is the demonstration: the string the macro produced equals the string you would have written by hand with the newlines and no leading spaces.

The interesting detail is that the interior indentation is preserved. The print line inside the function keeps its four extra spaces of relative indentation, and only the common prefix shared by every line is removed. That is what makes the macro usable for embedded code rather than merely for paragraph text.

The syntax is also worth noting. The macro is invoked with braces and a string literal directly, and the literal is a normal Rust string, so escape sequences behave as they would anywhere else.

## Raw strings and byte strings are handled the same way

The macro is not limited to ordinary string literals, and the README shows the other two forms working identically.

With a raw string literal, using the r# form so the inner quotes do not have to be escaped:

```rust
use indoc::indoc;

fn main() {
    let testing = indoc! {r#"
        def hello():
            print("Hello, world!")

        hello()
    "#};
    let expected = "def hello():\n    print(\"Hello, world!\")\n\nhello()\n";
    assert_eq!(testing, expected);
}
```

And with a byte string literal, where the expected value is a byte string too and the comparison is made on the slices:

```rust
use indoc::indoc;

fn main() {
    let testing = indoc! {b"
        def hello():
            print('Hello, world!')

        hello()
    "};
    let expected = b"def hello():\n    print('Hello, world!')\n\nhello()\n";
    assert_eq!(testing[..], expected[..]);
}
```

That last form is the one to know about, because it is the case where the macro is not just convenient but necessary. A byte string is not a str, so it cannot be compared with the ordinary string helpers, and hand-stripping indentation from a byte string is more work than writing the literal correctly in the first place.

The fact that all three forms behave the same way is a sign of how small the implementation is. The un-indenting rules are a handful of lines, and the macro applies them to whatever literal shape it is handed.

## Six formatting macros that fold indoc into the standard ones

The crate exports six additional macros to substitute for the standard library's formatting macros, and the README gives the exact equivalent for each.

formatdoc! takes a format string and arguments and is equivalent to format! with the format string wrapped in indoc!. printdoc! is the equivalent of print! with the same wrapping. eprintdoc! is the equivalent of eprint!. writedoc! takes a destination as well and is the equivalent of write! with the format string wrapped. concatdoc! is the equivalent of concat! with each string literal wrapped in indoc!. And panicdoc! is the equivalent of panic! with the format string wrapped.

The point of the set is that indoc! cannot be used directly with a format string, because format! and friends take their arguments as a comma separated list rather than as one literal, so the indoc wrapper has to be built into the macro itself. Each of the six exists so that you can keep the flush-left layout without giving up the formatting behaviour of the standard library.

A usage example combines two of them, with concatdoc! building a help text constant and printdoc! printing a request:

```rust
use indoc::{concatdoc, printdoc};

const HELP: &str = concatdoc! {"
    Usage: ", env!("CARGO_BIN_NAME"), " [options]

    Options:
        -h, --help
"};

fn main() {
    printdoc! {"
        GET {url}
        Accept: {mime}
        ",
        url = "http://localhost:8080",
        mime = "application/json",
    }
}
```

The concatdoc! form is the one to note. A help text assembled from several pieces, where each piece is a literal, gets the same un-indenting treatment applied to every literal in the list, so the assembled string lines up without the author having to trim each fragment.

## The four rules that define the behaviour

The README states the behaviour of the indoc! macro as four rules, and they are short enough to quote in full because they are the entire specification.

First, count the leading spaces of each line, ignoring the first line and any lines that are empty or contain spaces only. Second, take the minimum. Third, if the first line is empty, that is if the string begins with a newline, remove the first line. Fourth, remove the computed number of spaces from the beginning of each line.

Two of those rules carry most of the weight. Ignoring empty lines is what keeps a blank line in the middle of a snippet from collapsing the indentation of every other line, since a blank line has no common prefix to speak of. And removing the first line when the string begins with a newline is what lets the conventional formatting of a macro call, with the opening brace on its own line, produce a string that starts with the first real content rather than with an empty line.

The minimum is computed over leading spaces, not over all whitespace, and the count is then removed from the beginning of each line. A line with fewer spaces than the minimum is not possible, by construction, since the minimum is the smallest count, so every line keeps at least its own relative indentation.

This is the whole algorithm, and knowing it is enough to predict what the macro does with any input.

## unindent: the same logic for strings you only have at run time

The indenting logic is also available on its own, in a companion crate called unindent, and the README says this may be useful for processing strings that are not statically known at compile time.

The crate exposes two functions, unindent(&str) -> String and unindent_bytes(&[u8]) -> Vec<u8>, and the example is a runtime string with a common prefix:

```rust
use unindent::unindent;

fn main() {
    let indented = "
            line one
            line two";
    assert_eq!("line one\nline two", unindent(indented));
}
```

The difference between the two crates is the only real difference. indoc is a proc macro, so it can only work on literals it can see at compile time, which means it costs nothing at run time and cannot be applied to a value that arrives from a file or a network response. unindent is an ordinary function, so it works on any string, at the cost of allocating a new string when you call it.

Both are in the same repository, and the Cargo.toml declares a workspace with unindent as a member, with the unindent crate pinned as a path dependency in the dev-dependencies for the indoc package itself. The indoc package also depends on rustversion, both as a build dependency and a dev dependency, and on trybuild with the diff feature, which is the usual way to test that a proc macro's diagnostics and expansion behave as expected, including the compile-fail cases.

## Where indoc is the wrong tool

The limits here are narrow but they are real.

First, indoc is a compile-time tool. It only sees literals. If your indentation comes from a template, a configuration file or a string built at run time, indoc cannot help and the unindent crate is the one to reach for. A project that only needs run-time handling does not need the proc macro at all, and pulling in a proc macro for that reason is a compile-time cost for nothing.

Second, the transformation is fixed. It removes a common prefix and nothing else. It does not re-indent, it does not strip a prefix that is not common to every line, and it does not touch trailing whitespace. If your strings have inconsistent indentation by design, or you want a different common prefix computed per document, the four rules will not do what you want.

Third, it is a dependency with a floor. The declared rust-version is 1.71 and the edition is 2021, so an older toolchain cannot build it, and the crate is a proc macro, which means it participates in every compilation of your crate rather than only in the ones where you call it.

On the positive side, the licence is MIT OR Apache-2.0, which is the permissive dual licence this project uses throughout, and the documentation says contributions are dual licensed on the same terms unless stated otherwise. The release record is steady: 2.0.6 on 2025-03-03, 2.0.7 on 2025-10-21 and 2.0.8 on 2026-10-09, with the last push on 2026-10-09, so the project is still being worked on.

## Conclusion

Rust developers who embed code, help text or expected output in string literals should adopt indoc, because it removes the leading whitespace problem at compile time with no runtime cost and without changing the string's meaning. It is the wrong tool for text that is only known at run time, where the companion unindent crate is the right tool, Before adopting, check that your Rust version is at least 1.71, because that is the declared rust-version, and decide whether you want the indoc dependency at all, since the same four rules are short enough to apply by hand in a small crate.

## FAQ

### What does the indoc! macro actually do to a string literal?

It counts the leading spaces of each line, ignoring the first line and any empty or space-only lines, takes the minimum, removes the first line if the string begins with a newline, and removes that many spaces from the beginning of each line. All of this happens at compile time.

### How do I add indoc to a Rust project?

Add indoc = "2" under [dependencies] in Cargo.toml, then use the macro with use indoc::indoc. The crate is a proc macro with rust-version 1.71 and edition 2021, and the repository version is 2.0.8.

### Can indoc handle strings that are only known at run time?

Not with the indoc! macro, which is a proc macro and only sees literals. The README points to the companion unindent crate for that case, exposing unindent(&str) -> String and unindent_bytes(&[u8]) -> Vec<u8>.

### What are the extra macros indoc exports besides indoc!?

Six: formatdoc!, printdoc!, eprintdoc!, writedoc!, concatdoc! and panicdoc!. Each is the equivalent of the matching standard library formatting macro with the format string wrapped in indoc!, and concatdoc! is equivalent to concat! with each string literal wrapped in indoc!.

## Sources

- [dtolnay/indoc on GitHub](https://github.com/dtolnay/indoc)
- [Issues](https://github.com/dtolnay/indoc/issues)
- [License: Apache-2.0](https://github.com/dtolnay/indoc/blob/master/LICENSE)
- [README](https://github.com/dtolnay/indoc/blob/master/README.md)
- [Releases](https://github.com/dtolnay/indoc/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/dtolnay-indoc
