Skip to main content

pyo3/impl_/
unindent.rs

1#![warn(clippy::undocumented_unsafe_blocks)]
2
3use crate::impl_::concat::slice_copy_from_slice;
4use crate::platform::prelude::*;
5
6/// This is a reimplementation of the `indoc` crate's unindent functionality:
7///
8/// 1. Count the leading spaces of each line, ignoring the first line and any lines that are empty or contain spaces only.
9/// 2. Take the minimum.
10/// 3. If the first line is empty i.e. the string begins with a newline, remove the first line.
11/// 4. Remove the computed number of spaces from the beginning of each line.
12const fn unindent_bytes(bytes: &mut [u8]) -> usize {
13    // (1) + (2) - count leading spaces, take the minimum
14    let Some(to_unindent) = get_minimum_leading_spaces(bytes) else {
15        // all lines were empty, nothing to unindent
16        return bytes.len();
17    };
18
19    // now copy from the original buffer, bringing values forward as needed
20    let mut read_idx = 0;
21    let mut write_idx = 0;
22
23    // (3) - remove first line if it is empty
24    match consume_eol(bytes, read_idx) {
25        // skip empty first line
26        Some(eol) => read_idx = eol,
27        // copy non-empty first line as-is
28        None => {
29            (read_idx, write_idx) = copy_forward_until_eol(bytes, read_idx, write_idx);
30        }
31    };
32
33    // (4) - unindent remaining lines
34    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            // leading_spaces may be equal to or larger than to_unindent, only need to unindent
45            // the required amount, additional indentation is meaningful
46            read_idx += to_unindent;
47        }
48
49        // copy remainder of line
50        (read_idx, write_idx) = copy_forward_until_eol(bytes, read_idx, write_idx);
51    }
52
53    write_idx
54}
55
56/// Counts the minimum leading spaces of all non-empty lines except the first line.
57///
58/// Returns `None` if there are no non-empty lines except the first line.
59const fn get_minimum_leading_spaces(bytes: &[u8]) -> Option<usize> {
60    // scan for leading spaces (ignoring first line and empty lines)
61    let mut i = 0;
62
63    // skip first line
64    i = advance_to_next_line(bytes, i);
65
66    let mut to_unindent = None;
67
68    // for remaining lines, count leading spaces
69    while i < bytes.len() {
70        let line_leading_spaces = count_spaces(bytes, i);
71        i += line_leading_spaces;
72
73        // line only had spaces, ignore for the count
74        if let Some(eol) = consume_eol(bytes, i) {
75            i = eol;
76            continue;
77        }
78
79        // this line has content, consider its leading spaces
80        if let Some(current) = to_unindent {
81            // .unwrap_or(usize::MAX) not available in const fn
82            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
105/// Brings elements in `bytes` forward until `\n` (inclusive) or end of `source`.
106///
107/// `read_idx` must be greater than or equal to `write_idx`.
108const 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        // special case: treat end of buffer as EOL without consuming anything
138        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/// Helper for `py_run!` macro which unindents a string at compile time.
156#[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        // SAFETY: this removes only spaces and preserves all other contents
165        unsafe { ::core::str::from_utf8_unchecked(UNINDENTED.0.split_at(UNINDENTED.1).0) }
166    }};
167}
168
169pub use crate::unindent;
170
171/// Equivalent of the `unindent!` macro, but works at runtime.
172pub 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    // run const tests for each sample to ensure they work at compile time
217
218    #[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        // this variation on the test ensures full coverage (const eval not included in coverage)
239        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}
⚠️ Internal Docs ⚠️ Not Public API 👉 Official Docs Here