Skip to main content

indent_string/
lib.rs

1//! # indent-string — indent each line of a multi-line string
2//!
3//! Prepend an indentation to every line of a string — `foo\nbar` → ` foo\n bar`. A faithful
4//! Rust port of the widely-used
5//! [`indent-string`](https://www.npmjs.com/package/indent-string) npm package.
6//!
7//! ```
8//! use indent_string::indent_string;
9//!
10//! assert_eq!(indent_string("foo\nbar"), " foo\n bar");
11//! ```
12//!
13//! Use [`indent_string_with`] for a repeat count, a custom indent, or to also indent empty
14//! lines:
15//!
16//! ```
17//! use indent_string::indent_string_with;
18//!
19//! assert_eq!(indent_string_with("foo\nbar", 2, "\t", false), "\t\tfoo\n\t\tbar");
20//! assert_eq!(indent_string_with("a\n\nb", 1, " ", true), " a\n \n b");
21//! ```
22//!
23//! By default, empty and whitespace-only lines are left untouched (matching the reference's
24//! `/^(?!\s*$)/gm`); pass `include_empty_lines = true` to indent every line.
25//!
26//! **Zero dependencies** and `#![no_std]`.
27
28#![no_std]
29#![forbid(unsafe_code)]
30#![doc(html_root_url = "https://docs.rs/indent-string/0.1.0")]
31
32extern crate alloc;
33
34use alloc::string::{String, ToString};
35
36// Compile-test the README's examples as part of `cargo test`.
37#[cfg(doctest)]
38#[doc = include_str!("../README.md")]
39struct ReadmeDoctests;
40
41/// Characters matched by JavaScript's `\s` (used for the `\s*$` line check).
42fn is_js_whitespace(c: char) -> bool {
43    matches!(
44        c,
45        '\u{0009}'
46            | '\u{000A}'
47            | '\u{000B}'
48            | '\u{000C}'
49            | '\u{000D}'
50            | '\u{0020}'
51            | '\u{00A0}'
52            | '\u{1680}'
53            | '\u{2000}'
54            ..='\u{200A}'
55                | '\u{2028}'
56                | '\u{2029}'
57                | '\u{202F}'
58                | '\u{205F}'
59                | '\u{3000}'
60                | '\u{FEFF}'
61    )
62}
63
64/// Whether a line is empty or contains only whitespace (`\s*$` from the line start).
65fn is_blank(line: &str) -> bool {
66    line.chars().all(is_js_whitespace)
67}
68
69/// A JavaScript line terminator, after which `^` matches in a `/m` regex: `\n`, `\r`,
70/// `\u{2028}` (line separator), `\u{2029}` (paragraph separator).
71fn is_line_terminator(c: char) -> bool {
72    matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')
73}
74
75/// Indent each line of `string` by a single space.
76///
77/// Equivalent to [`indent_string_with(string, 1, " ", false)`](indent_string_with).
78///
79/// ```
80/// # use indent_string::indent_string;
81/// assert_eq!(indent_string("a\nb"), " a\n b");
82/// ```
83#[must_use]
84pub fn indent_string(string: &str) -> String {
85    indent_string_with(string, 1, " ", false)
86}
87
88/// Indent each line of `string` with `count` repetitions of `indent`.
89///
90/// When `include_empty_lines` is `false` (the default in [`indent_string`]), empty and
91/// whitespace-only lines are left unchanged. A `count` of `0` returns the string unchanged.
92///
93/// ```
94/// # use indent_string::indent_string_with;
95/// assert_eq!(indent_string_with("x\ny", 3, "-", false), "---x\n---y");
96/// ```
97#[must_use]
98pub fn indent_string_with(
99    string: &str,
100    count: usize,
101    indent: &str,
102    include_empty_lines: bool,
103) -> String {
104    if count == 0 {
105        return string.to_string();
106    }
107
108    let prefix_len = indent.len().saturating_mul(count);
109    let mut out = String::with_capacity(string.len() + prefix_len.saturating_add(prefix_len));
110    let maybe_indent = |line: &str, out: &mut String| {
111        if include_empty_lines || !is_blank(line) {
112            for _ in 0..count {
113                out.push_str(indent);
114            }
115        }
116    };
117
118    // `^` in a `/m` regex matches at the start of the string and after every line
119    // terminator, so each terminator-delimited segment is indented independently.
120    let mut rest = string;
121    loop {
122        let Some(position) = rest.find(is_line_terminator) else {
123            maybe_indent(rest, &mut out);
124            out.push_str(rest);
125            break;
126        };
127        let (line, after) = rest.split_at(position);
128        maybe_indent(line, &mut out);
129        out.push_str(line);
130        let mut chars = after.chars();
131        if let Some(terminator) = chars.next() {
132            out.push(terminator);
133        }
134        rest = chars.as_str();
135    }
136    out
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn defaults() {
145        assert_eq!(indent_string("foo\nbar"), " foo\n bar");
146        assert_eq!(indent_string("foo\r\nbar"), " foo\r\n bar");
147        assert_eq!(indent_string(""), "");
148        assert_eq!(indent_string("single"), " single");
149    }
150
151    #[test]
152    fn count_and_indent() {
153        assert_eq!(
154            indent_string_with("foo\nbar", 2, " ", false),
155            "  foo\n  bar"
156        );
157        assert_eq!(
158            indent_string_with("foo\nbar", 1, "\t", false),
159            "\tfoo\n\tbar"
160        );
161        assert_eq!(indent_string_with("x\ny", 3, "-", false), "---x\n---y");
162        assert_eq!(indent_string_with("foo", 0, " ", false), "foo");
163    }
164
165    #[test]
166    fn empty_lines() {
167        // Default: blank / whitespace-only lines are skipped.
168        assert_eq!(indent_string("foo\n\nbar"), " foo\n\n bar");
169        assert_eq!(indent_string("foo\n  \nbar"), " foo\n  \n bar");
170        // include_empty_lines: every line indented.
171        assert_eq!(
172            indent_string_with("foo\n\nbar", 1, " ", true),
173            " foo\n \n bar"
174        );
175        assert_eq!(indent_string_with("a\n  \nb", 1, " ", true), " a\n   \n b");
176    }
177
178    #[test]
179    fn carriage_return_is_a_line_terminator_with_include_empty_lines() {
180        // `^` (in `/m`) resets after `\r` too, so an empty segment appears between `\r\n`.
181        assert_eq!(indent_string_with("foo\r\nbar", 1, " ", true), " foo\r \n bar");
182        assert_eq!(indent_string_with("a\u{2028}b", 1, "-", true), "-a\u{2028}-b");
183    }
184}