indoc un-indents multiline string literals at compile time
Indented document literals for Rust
At a glance
- What is it?
- 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.
- Who is it for?
- 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.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 16 days ago.
- What is it written in?
- Mainly Rust, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 25, 2026, and from our analysis. They are not legal advice.
Editorial analysis
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:
[dependencies]
indoc = "2"And the usage is a macro call around a literal:
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:
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:
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:
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:
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.
Editorial 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.
Frequently asked questions
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!.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/dtolnay-indoc)Was this useful?