Skip to main content

rustpython_ruff_python_trivia/
textwrap.rs

1//! Functions related to adding and removing indentation from lines of
2//! text.
3
4use std::borrow::Cow;
5use std::cmp;
6
7use ruff_source_file::UniversalNewlines;
8
9use crate::PythonWhitespace;
10
11/// Indent each line by the given prefix.
12///
13/// # Examples
14///
15/// ```
16/// # use ruff_python_trivia::textwrap::indent;
17///
18/// assert_eq!(indent("First line.\nSecond line.\n", "  "),
19///            "  First line.\n  Second line.\n");
20/// ```
21///
22/// When indenting, trailing whitespace is stripped from the prefix.
23/// This means that empty lines remain empty afterwards:
24///
25/// ```
26/// # use ruff_python_trivia::textwrap::indent;
27///
28/// assert_eq!(indent("First line.\n\n\nSecond line.\n", "  "),
29///            "  First line.\n\n\n  Second line.\n");
30/// ```
31///
32/// Notice how `"\n\n\n"` remained as `"\n\n\n"`.
33///
34/// This feature is useful when you want to indent text and have a
35/// space between your prefix and the text. In this case, you _don't_
36/// want a trailing space on empty lines:
37///
38/// ```
39/// # use ruff_python_trivia::textwrap::indent;
40///
41/// assert_eq!(indent("foo = 123\n\nprint(foo)\n", "# "),
42///            "# foo = 123\n#\n# print(foo)\n");
43/// ```
44///
45/// Notice how `"\n\n"` became `"\n#\n"` instead of `"\n# \n"` which
46/// would have trailing whitespace.
47///
48/// Leading and trailing whitespace coming from the text itself is
49/// kept unchanged:
50///
51/// ```
52/// # use ruff_python_trivia::textwrap::indent;
53///
54/// assert_eq!(indent(" \t  Foo   ", "->"), "-> \t  Foo   ");
55/// ```
56pub fn indent<'a>(text: &'a str, prefix: &str) -> Cow<'a, str> {
57    if prefix.is_empty() {
58        return Cow::Borrowed(text);
59    }
60
61    let mut result = String::with_capacity(text.len() + prefix.len());
62    let trimmed_prefix = prefix.trim_whitespace_end();
63    for line in text.universal_newlines() {
64        if line.trim_whitespace().is_empty() {
65            result.push_str(trimmed_prefix);
66        } else {
67            result.push_str(prefix);
68        }
69        result.push_str(line.as_full_str());
70    }
71    Cow::Owned(result)
72}
73
74/// Indent only the first line by the given prefix.
75///
76/// This function is useful when you want to indent the first line of a multi-line
77/// expression while preserving the relative indentation of subsequent lines.
78///
79/// # Examples
80///
81/// ```
82/// # use ruff_python_trivia::textwrap::indent_first_line;
83///
84/// assert_eq!(indent_first_line("First line.\nSecond line.\n", "  "),
85///            "  First line.\nSecond line.\n");
86/// ```
87///
88/// When indenting, trailing whitespace is stripped from the prefix.
89/// This means that empty lines remain empty afterwards:
90///
91/// ```
92/// # use ruff_python_trivia::textwrap::indent_first_line;
93///
94/// assert_eq!(indent_first_line("\n\n\nSecond line.\n", "  "),
95///            "\n\n\nSecond line.\n");
96/// ```
97///
98/// Leading and trailing whitespace coming from the text itself is
99/// kept unchanged:
100///
101/// ```
102/// # use ruff_python_trivia::textwrap::indent_first_line;
103///
104/// assert_eq!(indent_first_line(" \t  Foo   ", "->"), "-> \t  Foo   ");
105/// ```
106pub fn indent_first_line<'a>(text: &'a str, prefix: &str) -> Cow<'a, str> {
107    if prefix.is_empty() {
108        return Cow::Borrowed(text);
109    }
110
111    let mut lines = text.universal_newlines();
112    let Some(first_line) = lines.next() else {
113        return Cow::Borrowed(text);
114    };
115
116    let mut result = String::with_capacity(text.len() + prefix.len());
117
118    // Indent only the first line
119    if first_line.trim_whitespace().is_empty() {
120        result.push_str(prefix.trim_whitespace_end());
121    } else {
122        result.push_str(prefix);
123    }
124    result.push_str(first_line.as_full_str());
125
126    // Add remaining lines without indentation
127    for line in lines {
128        result.push_str(line.as_full_str());
129    }
130
131    Cow::Owned(result)
132}
133
134/// Removes common leading whitespace from each line.
135///
136/// This function will look at each non-empty line and determine the
137/// maximum amount of whitespace that can be removed from all lines.
138///
139/// Lines that consist solely of whitespace are trimmed to a blank line.
140///
141/// ```
142/// # use ruff_python_trivia::textwrap::dedent;
143///
144/// assert_eq!(dedent("
145///     1st line
146///       2nd line
147///     3rd line
148/// "), "
149/// 1st line
150///   2nd line
151/// 3rd line
152/// ");
153/// ```
154pub fn dedent(text: &str) -> Cow<'_, str> {
155    // Find the minimum amount of leading whitespace on each line.
156    let prefix_len = text
157        .universal_newlines()
158        .fold(usize::MAX, |prefix_len, line| {
159            let leading_whitespace_len = line.len() - line.trim_whitespace_start().len();
160            if leading_whitespace_len == line.len() {
161                // Skip empty lines.
162                prefix_len
163            } else {
164                cmp::min(prefix_len, leading_whitespace_len)
165            }
166        });
167
168    // If there is no common prefix, no need to dedent.
169    if prefix_len == usize::MAX {
170        return Cow::Borrowed(text);
171    }
172
173    // Remove the common prefix from each line.
174    let mut result = String::with_capacity(text.len());
175    for line in text.universal_newlines() {
176        if line.trim_whitespace().is_empty() {
177            if let Some(line_ending) = line.line_ending() {
178                result.push_str(&line_ending);
179            }
180        } else {
181            result.push_str(&line.as_full_str()[prefix_len..]);
182        }
183    }
184    Cow::Owned(result)
185}
186
187/// Reduce a block's indentation to match the provided indentation.
188///
189/// This function looks at the first line in the block to determine the
190/// current indentation, then removes whitespace from each line to
191/// match the provided indentation.
192///
193/// Leading comments are ignored unless the block is only composed of comments.
194///
195/// Lines that are indented by _less_ than the indent of the first line
196/// are left unchanged.
197///
198/// Lines that consist solely of whitespace are trimmed to a blank line.
199///
200/// # Panics
201/// If the first line is indented by less than the provided indent.
202pub fn dedent_to(text: &str, indent: &str) -> Option<String> {
203    // Look at the indentation of the first non-empty line, to determine the "baseline" indentation.
204    let mut first_comment = None;
205    let existing_indent_len = text
206        .universal_newlines()
207        .find_map(|line| {
208            let trimmed = line.trim_whitespace_start();
209            if trimmed.is_empty() {
210                None
211            } else if trimmed.starts_with('#') && first_comment.is_none() {
212                first_comment = Some(line.len() - trimmed.len());
213                None
214            } else {
215                Some(line.len() - trimmed.len())
216            }
217        })
218        .unwrap_or(first_comment.unwrap_or_default());
219
220    if existing_indent_len < indent.len() {
221        return None;
222    }
223
224    // Determine the amount of indentation to remove.
225    let dedent_len = existing_indent_len - indent.len();
226
227    let mut result = String::with_capacity(text.len() + indent.len());
228    for line in text.universal_newlines() {
229        let trimmed = line.trim_whitespace_start();
230        if trimmed.is_empty() {
231            if let Some(line_ending) = line.line_ending() {
232                result.push_str(&line_ending);
233            }
234        } else {
235            // Determine the current indentation level.
236            let current_indent_len = line.len() - trimmed.len();
237            if current_indent_len < existing_indent_len {
238                // If the current indentation level is less than the baseline, keep it as is.
239                result.push_str(line.as_full_str());
240            } else {
241                // Otherwise, reduce the indentation level.
242                result.push_str(&line.as_full_str()[dedent_len..]);
243            }
244        }
245    }
246    Some(result)
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn indent_empty() {
255        assert_eq!(indent("\n", "  "), "\n");
256    }
257
258    #[test]
259    #[rustfmt::skip]
260    fn indent_nonempty() {
261        let text = [
262            "  foo\n",
263            "bar\n",
264            "  baz\n",
265        ].join("");
266        let expected = [
267            "//   foo\n",
268            "// bar\n",
269            "//   baz\n",
270        ].join("");
271        assert_eq!(indent(&text, "// "), expected);
272    }
273
274    #[test]
275    #[rustfmt::skip]
276    fn indent_empty_line() {
277        let text = [
278            "  foo",
279            "bar",
280            "",
281            "  baz",
282        ].join("\n");
283        let expected = [
284            "//   foo",
285            "// bar",
286            "//",
287            "//   baz",
288        ].join("\n");
289        assert_eq!(indent(&text, "// "), expected);
290    }
291
292    #[test]
293    #[rustfmt::skip]
294    fn indent_mixed_newlines() {
295        let text = [
296            "  foo\r\n",
297            "bar\n",
298            "  baz\r",
299        ].join("");
300        let expected = [
301            "//   foo\r\n",
302            "// bar\n",
303            "//   baz\r",
304        ].join("");
305        assert_eq!(indent(&text, "// "), expected);
306    }
307
308    #[test]
309    fn dedent_empty() {
310        assert_eq!(dedent(""), "");
311    }
312
313    #[test]
314    #[rustfmt::skip]
315    fn dedent_multi_line() {
316        let x = [
317            "    foo",
318            "  bar",
319            "    baz",
320        ].join("\n");
321        let y = [
322            "  foo",
323            "bar",
324            "  baz"
325        ].join("\n");
326        assert_eq!(dedent(&x), y);
327    }
328
329    #[test]
330    #[rustfmt::skip]
331    fn dedent_empty_line() {
332        let x = [
333            "    foo",
334            "  bar",
335            "   ",
336            "    baz"
337        ].join("\n");
338        let y = [
339            "  foo",
340            "bar",
341            "",
342            "  baz"
343        ].join("\n");
344        assert_eq!(dedent(&x), y);
345    }
346
347    #[test]
348    #[rustfmt::skip]
349    fn dedent_blank_line() {
350        let x = [
351            "      foo",
352            "",
353            "        bar",
354            "          foo",
355            "          bar",
356            "          baz",
357        ].join("\n");
358        let y = [
359            "foo",
360            "",
361            "  bar",
362            "    foo",
363            "    bar",
364            "    baz",
365        ].join("\n");
366        assert_eq!(dedent(&x), y);
367    }
368
369    #[test]
370    #[rustfmt::skip]
371    fn dedent_whitespace_line() {
372        let x = [
373            "      foo",
374            " ",
375            "        bar",
376            "          foo",
377            "          bar",
378            "          baz",
379        ].join("\n");
380        let y = [
381            "foo",
382            "",
383            "  bar",
384            "    foo",
385            "    bar",
386            "    baz",
387        ].join("\n");
388        assert_eq!(dedent(&x), y);
389    }
390
391    #[test]
392    #[rustfmt::skip]
393    fn dedent_mixed_whitespace() {
394        let x = [
395            "\tfoo",
396            "  bar",
397        ].join("\n");
398        let y = [
399            "foo",
400            " bar",
401        ].join("\n");
402        assert_eq!(dedent(&x), y);
403    }
404
405    #[test]
406    #[rustfmt::skip]
407    fn dedent_tabbed_whitespace() {
408        let x = [
409            "\t\tfoo",
410            "\t\t\tbar",
411        ].join("\n");
412        let y = [
413            "foo",
414            "\tbar",
415        ].join("\n");
416        assert_eq!(dedent(&x), y);
417    }
418
419    #[test]
420    #[rustfmt::skip]
421    fn dedent_mixed_tabbed_whitespace() {
422        let x = [
423            "\t  \tfoo",
424            "\t  \t\tbar",
425        ].join("\n");
426        let y = [
427            "foo",
428            "\tbar",
429        ].join("\n");
430        assert_eq!(dedent(&x), y);
431    }
432
433    #[test]
434    #[rustfmt::skip]
435    fn dedent_preserve_no_terminating_newline() {
436        let x = [
437            "  foo",
438            "    bar",
439        ].join("\n");
440        let y = [
441            "foo",
442            "  bar",
443        ].join("\n");
444        assert_eq!(dedent(&x), y);
445    }
446
447    #[test]
448    #[rustfmt::skip]
449    fn dedent_mixed_newlines() {
450        let x = [
451            "    foo\r\n",
452            "  bar\n",
453            "    baz\r",
454        ].join("");
455        let y = [
456            "  foo\r\n",
457            "bar\n",
458            "  baz\r"
459        ].join("");
460        assert_eq!(dedent(&x), y);
461    }
462
463    #[test]
464    fn dedent_non_python_whitespace() {
465        let text = r"        C = int(f.rea1,0],[-1,0,1]],
466              [[-1,-1,1],[1,1,-1],[0,-1,0]],
467              [[-1,-1,-1],[1,1,0],[1,0,1]]
468             ]";
469        assert_eq!(dedent(text), text);
470    }
471
472    #[test]
473    fn indent_first_line_empty() {
474        assert_eq!(indent_first_line("\n", "  "), "\n");
475    }
476
477    #[test]
478    #[rustfmt::skip]
479    fn indent_first_line_nonempty() {
480        let text = [
481            "  foo\n",
482            "bar\n",
483            "  baz\n",
484        ].join("");
485        let expected = [
486            "//   foo\n",
487            "bar\n",
488            "  baz\n",
489        ].join("");
490        assert_eq!(indent_first_line(&text, "// "), expected);
491    }
492
493    #[test]
494    #[rustfmt::skip]
495    fn indent_first_line_empty_line() {
496        let text = [
497            "  foo",
498            "bar",
499            "",
500            "  baz",
501        ].join("\n");
502        let expected = [
503            "//   foo",
504            "bar",
505            "",
506            "  baz",
507        ].join("\n");
508        assert_eq!(indent_first_line(&text, "// "), expected);
509    }
510
511    #[test]
512    #[rustfmt::skip]
513    fn indent_first_line_mixed_newlines() {
514        let text = [
515            "  foo\r\n",
516            "bar\n",
517            "  baz\r",
518        ].join("");
519        let expected = [
520            "//   foo\r\n",
521            "bar\n",
522            "  baz\r",
523        ].join("");
524        assert_eq!(indent_first_line(&text, "// "), expected);
525    }
526
527    #[test]
528    #[rustfmt::skip]
529    fn adjust_indent() {
530        let x = [
531            "    foo",
532            "  bar",
533            "   ",
534            "    baz"
535        ].join("\n");
536        let y = [
537            "  foo",
538            "  bar",
539            "",
540            "  baz"
541        ].join("\n");
542        assert_eq!(dedent_to(&x, "  "), Some(y));
543
544        let x = [
545            "    foo",
546            "        bar",
547            "    baz",
548        ].join("\n");
549        let y = [
550            "foo",
551            "    bar",
552            "baz"
553        ].join("\n");
554        assert_eq!(dedent_to(&x, ""), Some(y));
555
556        let x = [
557            "  # foo",
558            "    # bar",
559            "# baz"
560        ].join("\n");
561        let y = [
562            "  # foo",
563            "  # bar",
564            "# baz"
565        ].join("\n");
566        assert_eq!(dedent_to(&x, "  "), Some(y));
567
568        let x = [
569            "  # foo",
570            "    bar",
571            "      baz"
572        ].join("\n");
573        let y = [
574            "  # foo",
575            "  bar",
576            "    baz"
577        ].join("\n");
578        assert_eq!(dedent_to(&x, "  "), Some(y));
579    }
580}