1#![warn(clippy::undocumented_unsafe_blocks)]
2
3use crate::impl_::concat::slice_copy_from_slice;
4use crate::platform::prelude::*;
5
6const fn unindent_bytes(bytes: &mut [u8]) -> usize {
13 let Some(to_unindent) = get_minimum_leading_spaces(bytes) else {
15 return bytes.len();
17 };
18
19 let mut read_idx = 0;
21 let mut write_idx = 0;
22
23 match consume_eol(bytes, read_idx) {
25 Some(eol) => read_idx = eol,
27 None => {
29 (read_idx, write_idx) = copy_forward_until_eol(bytes, read_idx, write_idx);
30 }
31 };
32
33 while read_idx < bytes.len() {
35 let leading_spaces = count_spaces(bytes, read_idx);
36
37 if leading_spaces < to_unindent {
38 read_idx += leading_spaces;
39 assert!(
40 consume_eol(bytes, read_idx).is_some(),
41 "removed fewer spaces than expected on non-empty line"
42 );
43 } else {
44 read_idx += to_unindent;
47 }
48
49 (read_idx, write_idx) = copy_forward_until_eol(bytes, read_idx, write_idx);
51 }
52
53 write_idx
54}
55
56const fn get_minimum_leading_spaces(bytes: &[u8]) -> Option<usize> {
60 let mut i = 0;
62
63 i = advance_to_next_line(bytes, i);
65
66 let mut to_unindent = None;
67
68 while i < bytes.len() {
70 let line_leading_spaces = count_spaces(bytes, i);
71 i += line_leading_spaces;
72
73 if let Some(eol) = consume_eol(bytes, i) {
75 i = eol;
76 continue;
77 }
78
79 if let Some(current) = to_unindent {
81 if line_leading_spaces < current {
83 to_unindent = Some(line_leading_spaces);
84 }
85 } else {
86 to_unindent = Some(line_leading_spaces);
87 }
88
89 i = advance_to_next_line(bytes, i);
90 }
91
92 to_unindent
93}
94
95const fn advance_to_next_line(bytes: &[u8], mut i: usize) -> usize {
96 while i < bytes.len() {
97 if let Some(eol) = consume_eol(bytes, i) {
98 return eol;
99 }
100 i += 1;
101 }
102 i
103}
104
105const fn copy_forward_until_eol(
109 bytes: &mut [u8],
110 mut read_idx: usize,
111 mut write_idx: usize,
112) -> (usize, usize) {
113 assert!(read_idx >= write_idx);
114 while read_idx < bytes.len() {
115 let value = bytes[read_idx];
116 bytes[write_idx] = value;
117 read_idx += 1;
118 write_idx += 1;
119 if value == b'\n' {
120 break;
121 }
122 }
123 (read_idx, write_idx)
124}
125
126const fn count_spaces(bytes: &[u8], mut i: usize) -> usize {
127 let mut count = 0;
128 while i < bytes.len() && bytes[i] == b' ' {
129 count += 1;
130 i += 1;
131 }
132 count
133}
134
135const fn consume_eol(bytes: &[u8], i: usize) -> Option<usize> {
136 if bytes.len() == i {
137 Some(i)
139 } else if bytes.len() > i && bytes[i] == b'\n' {
140 Some(i + 1)
141 } else if bytes[i] == b'\r' && bytes.len() > i + 1 && bytes[i + 1] == b'\n' {
142 Some(i + 2)
143 } else {
144 None
145 }
146}
147
148pub const fn unindent_sized<const N: usize>(src: &[u8]) -> ([u8; N], usize) {
149 let mut out: [u8; N] = [0; N];
150 slice_copy_from_slice(&mut out, src);
151 let new_len = unindent_bytes(&mut out);
152 (out, new_len)
153}
154
155#[macro_export]
157#[doc(hidden)]
158macro_rules! unindent {
159 ($value:expr) => {{
160 const RAW: &str = $value;
161 const LEN: usize = RAW.len();
162 const UNINDENTED: ([u8; LEN], usize) =
163 $crate::impl_::unindent::unindent_sized::<LEN>(RAW.as_bytes());
164 unsafe { ::core::str::from_utf8_unchecked(UNINDENTED.0.split_at(UNINDENTED.1).0) }
166 }};
167}
168
169pub use crate::unindent;
170
171pub fn unindent(s: &str) -> String {
173 let mut bytes = s.as_bytes().to_owned();
174 let unindented_size = unindent_bytes(&mut bytes);
175 bytes.resize(unindented_size, 0);
176 String::from_utf8(bytes).unwrap()
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 const SAMPLE_1_WITH_FIRST_LINE: &str = " first line
184 line one
185
186 line two
187 ";
188
189 const UNINDENTED_1: &str = " first line\nline one\n\n line two\n";
190
191 const SAMPLE_2_EMPTY_FIRST_LINE: &str = "
192 line one
193
194 line two
195 ";
196 const UNINDENTED_2: &str = "line one\n\n line two\n";
197
198 const SAMPLE_3_NO_INDENT: &str = "
199no indent
200 here";
201
202 const UNINDENTED_3: &str = "no indent\n here";
203
204 const SAMPLE_4_NOOP: &str = "no indent\nhere\n but here";
205
206 const SAMPLE_5_EMPTY: &str = " \n \n";
207
208 const ALL_CASES: &[(&str, &str)] = &[
209 (SAMPLE_1_WITH_FIRST_LINE, UNINDENTED_1),
210 (SAMPLE_2_EMPTY_FIRST_LINE, UNINDENTED_2),
211 (SAMPLE_3_NO_INDENT, UNINDENTED_3),
212 (SAMPLE_4_NOOP, SAMPLE_4_NOOP),
213 (SAMPLE_5_EMPTY, SAMPLE_5_EMPTY),
214 ];
215
216 #[test]
219 fn test_unindent_const() {
220 const UNINDENTED: &str = unindent!(SAMPLE_1_WITH_FIRST_LINE);
221 assert_eq!(UNINDENTED, UNINDENTED_1);
222 }
223
224 #[test]
225 fn test_unindent_const_removes_empty_first_line() {
226 const UNINDENTED: &str = unindent!(SAMPLE_2_EMPTY_FIRST_LINE);
227 assert_eq!(UNINDENTED, UNINDENTED_2);
228 }
229
230 #[test]
231 fn test_unindent_const_no_indent() {
232 const UNINDENTED: &str = unindent!(SAMPLE_3_NO_INDENT);
233 assert_eq!(UNINDENTED, UNINDENTED_3);
234 }
235
236 #[test]
237 fn test_unindent_macro_runtime() {
238 const INDENTED: &str = SAMPLE_1_WITH_FIRST_LINE;
240 const LEN: usize = INDENTED.len();
241 let (unindented, unindented_size) = unindent_sized::<LEN>(INDENTED.as_bytes());
242 let unindented = core::str::from_utf8(&unindented[..unindented_size]).unwrap();
243 assert_eq!(unindented, UNINDENTED_1);
244 }
245
246 #[test]
247 fn test_unindent_function() {
248 for (indented, expected) in ALL_CASES {
249 let unindented = unindent(indented);
250 assert_eq!(&unindented, expected);
251 }
252 }
253}