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}