Skip to main content

strip_indent/
lib.rs

1//! # strip-indent — remove leading indentation from a multi-line string
2//!
3//! Strip the leading whitespace from every line of a string, based on the least-indented
4//! line — handy for normalizing indented template/heredoc text. A faithful Rust port of the
5//! widely-used [`strip-indent`](https://www.npmjs.com/package/strip-indent) npm package
6//! (which also bundles [`min-indent`](https://www.npmjs.com/package/min-indent)).
7//!
8//! ```
9//! use strip_indent::strip_indent;
10//!
11//! assert_eq!(strip_indent("    foo\n      bar"), "foo\n  bar");
12//! assert_eq!(strip_indent("\tfoo\n\t\tbar"), "foo\n\tbar");
13//! ```
14//!
15//! Indentation is counted in characters (a tab counts as one), and lines that are empty or
16//! contain only whitespace are ignored when computing the common indent.
17//!
18//! **Zero dependencies** and `#![no_std]`.
19
20#![no_std]
21#![forbid(unsafe_code)]
22#![doc(html_root_url = "https://docs.rs/strip-indent/0.1.0")]
23
24extern crate alloc;
25
26use alloc::string::{String, ToString};
27
28// Compile-test the README's examples as part of `cargo test`.
29#[cfg(doctest)]
30#[doc = include_str!("../README.md")]
31struct ReadmeDoctests;
32
33/// Characters matched by JavaScript's `\s` (used for the `(?=\S)` lookahead).
34fn is_js_whitespace(c: char) -> bool {
35    matches!(
36        c,
37        '\u{0009}'
38            | '\u{000A}'
39            | '\u{000B}'
40            | '\u{000C}'
41            | '\u{000D}'
42            | '\u{0020}'
43            | '\u{00A0}'
44            | '\u{1680}'
45            | '\u{2000}'
46            ..='\u{200A}'
47                | '\u{2028}'
48                | '\u{2029}'
49                | '\u{202F}'
50                | '\u{205F}'
51                | '\u{3000}'
52                | '\u{FEFF}'
53    )
54}
55
56/// Count the leading spaces/tabs of `line` if it has a non-whitespace character after them.
57///
58/// Returns `None` for blank or whitespace-only lines (matching `/^[ \t]*(?=\S)/`).
59fn line_indent(line: &str) -> Option<usize> {
60    let bytes = line.as_bytes();
61    let mut indent = 0;
62    while indent < bytes.len() && (bytes[indent] == b' ' || bytes[indent] == b'\t') {
63        indent += 1;
64    }
65    match line[indent..].chars().next() {
66        Some(c) if !is_js_whitespace(c) => Some(indent),
67        _ => None,
68    }
69}
70
71/// The smallest indentation (in leading spaces/tabs) of any non-blank line.
72///
73/// Blank and whitespace-only lines are ignored. Returns `0` when there is no non-blank line.
74///
75/// ```
76/// # use strip_indent::min_indent;
77/// assert_eq!(min_indent("  a\n    b"), 2);
78/// assert_eq!(min_indent("a\n  b"), 0);
79/// assert_eq!(min_indent("\n\n"), 0);
80/// ```
81#[must_use]
82pub fn min_indent(string: &str) -> usize {
83    string
84        .split('\n')
85        .filter_map(line_indent)
86        .min()
87        .unwrap_or(0)
88}
89
90/// Strip the common leading indentation from every line of `string`.
91///
92/// The amount removed is [`min_indent`]; lines with fewer leading spaces/tabs than that
93/// (e.g. blank lines) are left as-is. Returns `string` unchanged when the common indent is
94/// zero.
95///
96/// ```
97/// # use strip_indent::strip_indent;
98/// assert_eq!(strip_indent("  a\n\n   b"), "a\n\n b");
99/// ```
100#[must_use]
101pub fn strip_indent(string: &str) -> String {
102    let indent = min_indent(string);
103    if indent == 0 {
104        return string.to_string();
105    }
106
107    let mut out = String::with_capacity(string.len());
108    for (index, line) in string.split('\n').enumerate() {
109        if index > 0 {
110            out.push('\n');
111        }
112        let bytes = line.as_bytes();
113        let mut leading = 0;
114        while leading < bytes.len() && (bytes[leading] == b' ' || bytes[leading] == b'\t') {
115            leading += 1;
116        }
117        if leading >= indent {
118            // Leading spaces/tabs are ASCII, so byte offset == character count.
119            out.push_str(&line[indent..]);
120        } else {
121            out.push_str(line);
122        }
123    }
124    out
125}
126
127/// Like [`strip_indent`], but first removes leading and trailing whitespace-only lines.
128///
129/// ```
130/// # use strip_indent::dedent;
131/// assert_eq!(dedent("\n\n   foo\n   bar\n\n"), "foo\nbar");
132/// ```
133#[must_use]
134pub fn dedent(string: &str) -> String {
135    strip_indent(trim_blank_lines(string))
136}
137
138/// Remove leading `(?:[ \t]*\r?\n)+` and trailing `(?:\r?\n[ \t]*)+`.
139fn trim_blank_lines(string: &str) -> &str {
140    let bytes = string.as_bytes();
141    let is_space = |b: u8| b == b' ' || b == b'\t';
142
143    // Leading whitespace-only lines.
144    let mut start = 0;
145    loop {
146        let mut j = start;
147        while j < bytes.len() && is_space(bytes[j]) {
148            j += 1;
149        }
150        if j < bytes.len() && bytes[j] == b'\n' {
151            start = j + 1;
152        } else if j + 1 < bytes.len() && bytes[j] == b'\r' && bytes[j + 1] == b'\n' {
153            start = j + 2;
154        } else {
155            break;
156        }
157    }
158
159    // Trailing whitespace-only lines.
160    let mut end = bytes.len();
161    loop {
162        let mut j = end;
163        while j > start && is_space(bytes[j - 1]) {
164            j -= 1;
165        }
166        if j > start && bytes[j - 1] == b'\n' {
167            let mut newline_start = j - 1;
168            if newline_start > start && bytes[newline_start - 1] == b'\r' {
169                newline_start -= 1;
170            }
171            end = newline_start;
172        } else {
173            break;
174        }
175    }
176
177    &string[start..end]
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn strips_spaces_and_tabs() {
186        assert_eq!(strip_indent("    foo\n      bar"), "foo\n  bar");
187        assert_eq!(strip_indent("\tfoo\n\t\tbar"), "foo\n\tbar");
188        assert_eq!(strip_indent("   only"), "only");
189        assert_eq!(strip_indent("\n   x"), "\nx");
190        assert_eq!(strip_indent("  mix\n\ttab"), " mix\ntab");
191    }
192
193    #[test]
194    fn blank_lines_and_no_indent() {
195        assert_eq!(strip_indent("  a\n\n   b"), "a\n\n b");
196        assert_eq!(strip_indent("no indent"), "no indent");
197        assert_eq!(strip_indent(""), "");
198        assert_eq!(strip_indent("\n\n"), "\n\n");
199        assert_eq!(strip_indent("   trailing   \n   x"), "trailing   \nx");
200    }
201
202    #[test]
203    fn min_indent_values() {
204        assert_eq!(min_indent("    foo\n      bar"), 4);
205        assert_eq!(min_indent("\tfoo\n\t\tbar"), 1);
206        assert_eq!(min_indent("  a\n\n   b"), 2);
207        assert_eq!(min_indent("no indent"), 0);
208        assert_eq!(min_indent("   only"), 3);
209    }
210
211    #[test]
212    fn dedent_trims_blank_lines() {
213        assert_eq!(dedent("\n\n   foo\n   bar\n\n"), "foo\nbar");
214        assert_eq!(dedent("   \n  a\n  b\n   "), "a\nb");
215        assert_eq!(dedent("no\nchange"), "no\nchange");
216        assert_eq!(dedent("\r\n   x\r\n   y\r\n"), "x\r\ny");
217        assert_eq!(dedent(""), "");
218    }
219}