Repository: mgeisler/textwrap (language: rust)

Issue:
The indent function always adds a final \n, even if the input string has no \n
Example:

```rust
// this test fails currently
#[test]
fn indented_text_should_have_the_same_number_of_lines_as_the_original_text() {
    let texts = ["foo\nbar", "foo\nbar\n", "foo\nbar\nbaz"];
    for original in texts.iter() {
        let indented = indent(original, "");
        assert_eq!(&indented, original);
    }
}
```

There should be the same number of lines in the input and output (unless the prefix contains a newline).

[Python tests reference](https://github.com/python/cpython/blob/e42b705188271da108de42b55d9344642170aa2b/Lib/test/test_textwrap.py), [python source reference](https://github.com/python/cpython/blob/a33f2c2bae759fc9d06e1c032fd2026135f2df45/Lib/textwrap.py#L465), can't be implemented the same way in rust without the [`split_inclusive`](https://github.com/rust-lang/rust/issues/72360) feature. (cf. python's [`splitlines`](https://docs.python.org/3/library/stdtypes.html#str.splitlines))

<details>
<summary>Python script passes the same test</summary>

```python
from textwrap import indent

for original in ["foo\nbar", "foo\nbar\n", "foo\nbar\nbaz"]:
    indented = indent(original, "")
    assert original == indented
```
</details>
The indent function always adds a final \n, even if the input string has no \n
Example:

```rust
// this test fails currently
#[test]
fn indented_text_should_have_the_same_number_of_lines_as_the_original_text() {
    let texts = ["foo\nbar", "foo\nbar\n", "foo\nbar\nbaz"];
    for original in texts.iter() {
        let indented = indent(original, "");
        assert_eq!(&indented, original);
    }
}
```

There should be the same number of lines in the input and output (unless the prefix contains a newline).

[Python tests reference](https://github.com/python/cpython/blob/e42b705188271da108de42b55d9344642170aa2b/Lib/test/test_textwrap.py), [python source reference](https://github.com/python/cpython/blob/a33f2c2bae759fc9d06e1c032fd2026135f2df45/Lib/textwrap.py#L465), can't be implemented the same way in rust without the [`split_inclusive`](https://github.com/rust-lang/rust/issues/72360) feature. (cf. python's [`splitlines`](https://docs.python.org/3/library/stdtypes.html#str.splitlines))

<details>
<summary>Python script passes the same test</summary>

```python
from textwrap import indent

for original in ["foo\nbar", "foo\nbar\n", "foo\nbar\nbaz"]:
    indented = indent(original, "")
    assert original == indented
```
</details>


Discussion / hints:

Fix patch:
diff --git a/src/indentation.rs b/src/indentation.rs
index ca192490..cc2351f2 100644
--- a/src/indentation.rs
+++ b/src/indentation.rs
@@ -18,8 +18,7 @@
 /// ");
 /// ```
 ///
-/// Empty lines (lines consisting only of whitespace) are not indented
-/// and the whitespace is replaced by a single newline (`\n`):
+/// Lines consisting only of whitespace are kept unchanged:
 ///
 /// ```
 /// use textwrap::indent;
@@ -34,7 +33,7 @@
 /// ->Foo
 ///
 /// ->Bar
-///
+///   \t
 /// ->Baz
 /// ");
 /// ```
@@ -45,17 +44,21 @@
 /// ```
 /// use textwrap::indent;
 ///
-/// assert_eq!(indent(" \t  Foo   ", "->"), "-> \t  Foo   \n");
+/// assert_eq!(indent(" \t  Foo   ", "->"), "-> \t  Foo   ");
 /// ```
 pub fn indent(s: &str, prefix: &str) -> String {
     let mut result = String::new();
-    for line in s.lines() {
-        if line.chars().any(|c| !c.is_whitespace()) {
+
+    for (idx, line) in s.split('\n').enumerate() {
+        if idx > 0 {
+            result.push('\n');
+        }
+        if !line.trim().is_empty() {
             result.push_str(prefix);
-            result.push_str(line);
         }
-        result.push('\n');
+        result.push_str(line);
     }
+
     result
 }
 
@@ -138,12 +141,6 @@ pub fn dedent(s: &str) -> String {
 mod tests {
     use super::*;
 
-    /// Add newlines. Ensures that the final line in the vector also
-    /// has a newline.
-    fn add_nl(lines: &[&str]) -> String {
-        lines.join("\n") + "\n"
-    }
-
     #[test]
     fn indent_empty() {
         assert_eq!(indent("\n", "  "), "\n");
@@ -152,27 +149,35 @@ mod tests {
     #[test]
     #[rustfmt::skip]
     fn indent_nonempty() {
-        let x = vec!["  foo",
-                     "bar",
-                     "  baz"];
-        let y = vec!["//  foo",
-                     "//bar",
-                     "//  baz"];
-        assert_eq!(indent(&add_nl(&x), "//"), add_nl(&y));
+        let text = [
+            "  foo\n",
+            "bar\n",
+            "  baz\n",
+        ].join("");
+        let expected = [
+            "//  foo\n",
+            "//bar\n",
+            "//  baz\n",
+        ].join("");
+        assert_eq!(indent(&text, "//"), expected);
     }
 
     #[test]
     #[rustfmt::skip]
     fn indent_empty_line() {
-        let x = vec!["  foo",
-                     "bar",
-                     "",
-                     "  baz"];
-        let y = vec!["//  foo",
-                     "//bar",
-                     "",
-                     "//  baz"];
-        assert_eq!(indent(&add_nl(&x), "//"), add_nl(&y));
+        let text = [
+            "  foo",
+            "bar",
+            "",
+            "  baz",
+        ].join("\n");
+        let expected = [
+            "//  foo",
+            "//bar",
+            "",
+            "//  baz",
+        ].join("\n");
+        assert_eq!(indent(&text, "//"), expected);
     }
 
     #[test]
@@ -183,112 +188,148 @@ mod tests {
     #[test]
     #[rustfmt::skip]
     fn dedent_multi_line() {
-        let x = vec!["    foo",
-                     "  bar",
-                     "    baz"];
-        let y = vec!["  foo",
-                     "bar",
-                     "  baz"];
-        assert_eq!(dedent(&add_nl(&x)), add_nl(&y));
+        let x = [
+            "    foo",
+            "  bar",
+            "    baz",
+        ].join("\n");
+        let y = [
+            "  foo",
+            "bar",
+            "  baz"
+        ].join("\n");
+        assert_eq!(dedent(&x), y);
     }
 
     #[test]
     #[rustfmt::skip]
     fn dedent_empty_line() {
-        let x = vec!["    foo",
-                     "  bar",
-                     "   ",
-                     "    baz"];
-        let y = vec!["  foo",
-                     "bar",
-                     "",
-                     "  baz"];
-        assert_eq!(dedent(&add_nl(&x)), add_nl(&y));
+        let x = [
+            "    foo",
+            "  bar",
+            "   ",
+            "    baz"
+        ].join("\n");
+        let y = [
+            "  foo",
+            "bar",
+            "",
+            "  baz"
+        ].join("\n");
+        assert_eq!(dedent(&x), y);
     }
 
     #[test]
     #[rustfmt::skip]
     fn dedent_blank_line() {
-        let x = vec!["      foo",
-                     "",
-                     "        bar",
-                     "          foo",
-                     "          bar",
-                     "          baz"];
-        let y = vec!["foo",
-                     "",
-                     "  bar",
-                     "    foo",
-                     "    bar",
-                     "    baz"];
-        assert_eq!(dedent(&add_nl(&x)), add_nl(&y));
+        let x = [
+            "      foo",
+            "",
+            "        bar",
+            "          foo",
+            "          bar",
+            "          baz",
+        ].join("\n");
+        let y = [
+            "foo",
+            "",
+            "  bar",
+            "    foo",
+            "    bar",
+            "    baz",
+        ].join("\n");
+        assert_eq!(dedent(&x), y);
     }
 
     #[test]
     #[rustfmt::skip]
     fn dedent_whitespace_line() {
-        let x = vec!["      foo",
-                     " ",
-                     "        bar",
-                     "          foo",
-                     "          bar",
-                     "          baz"];
-        let y = vec!["foo",
-                     "",
-                     "  bar",
-                     "    foo",
-                     "    bar",
-                     "    baz"];
-        assert_eq!(dedent(&add_nl(&x)), add_nl(&y));
+        let x = [
+            "      foo",
+            " ",
+            "        bar",
+            "          foo",
+            "          bar",
+            "          baz",
+        ].join("\n");
+        let y = [
+            "foo",
+            "",
+            "  bar",
+            "    foo",
+            "    bar",
+            "    baz",
+        ].join("\n");
+        assert_eq!(dedent(&x), y);
     }
 
     #[test]
     #[rustfmt::skip]
     fn dedent_mixed_whitespace() {
-        let x = vec!["\tfoo",
-                     "  bar"];
-        let y = vec!["\tfoo",
-                     "  bar"];
-        assert_eq!(dedent(&add_nl(&x)), add_nl(&y));
+        let x = [
+            "\tfoo",
+            "  bar",
+        ].join("\n");
+        let y = [
+            "\tfoo",
+            "  bar",
+        ].join("\n");
+        assert_eq!(dedent(&x), y);
     }
 
     #[test]
     #[rustfmt::skip]
     fn dedent_tabbed_whitespace() {
-        let x = vec!["\t\tfoo",
-                     "\t\t\tbar"];
-        let y = vec!["foo",
-                     "\tbar"];
-        assert_eq!(dedent(&add_nl(&x)), add_nl(&y));
+        let x = [
+            "\t\tfoo",
+            "\t\t\tbar",
+        ].join("\n");
+        let y = [
+            "foo",
+            "\tbar",
+        ].join("\n");
+        assert_eq!(dedent(&x), y);
     }
 
     #[test]
     #[rustfmt::skip]
     fn dedent_mixed_tabbed_whitespace() {
-        let x = vec!["\t  \tfoo",
-                     "\t  \t\tbar"];
-        let y = vec!["foo",
-                     "\tbar"];
-        assert_eq!(dedent(&add_nl(&x)), add_nl(&y));
+        let x = [
+            "\t  \tfoo",
+            "\t  \t\tbar",
+        ].join("\n");
+        let y = [
+            "foo",
+            "\tbar",
+        ].join("\n");
+        assert_eq!(dedent(&x), y);
     }
 
     #[test]
     #[rustfmt::skip]
     fn dedent_mixed_tabbed_whitespace2() {
-        let x = vec!["\t  \tfoo",
-                     "\t    \tbar"];
-        let y = vec!["\tfoo",
-                     "  \tbar"];
-        assert_eq!(dedent(&add_nl(&x)), add_nl(&y));
+        let x = [
+            "\t  \tfoo",
+            "\t    \tbar",
+        ].join("\n");
+        let y = [
+            "\tfoo",
+            "  \tbar",
+        ].join("\n");
+        assert_eq!(dedent(&x), y);
     }
 
     #[test]
     #[rustfmt::skip]
     fn dedent_preserve_no_terminating_newline() {
-        let x = vec!["  foo",
-                     "    bar"].join("\n");
-        let y = vec!["foo",
-                     "  bar"].join("\n");
+        let x = [
+            "  foo",
+            "    bar",
+        ].join("\n");
+        let y = [
+            "foo",
+            "  bar",
+        ].join("\n");
         assert_eq!(dedent(&x), y);
     }
 }


Test patch:
diff --git a/tests/indent.rs b/tests/indent.rs
new file mode 100644
index 00000000..9dd5ad26
--- /dev/null
+++ b/tests/indent.rs
@@ -0,0 +1,88 @@
+/// tests cases ported over from python standard library
+use textwrap::{dedent, indent};
+
+const ROUNDTRIP_CASES: [&str; 3] = [
+    // basic test case
+    "Hi.\nThis is a test.\nTesting.",
+    // include a blank line
+    "Hi.\nThis is a test.\n\nTesting.",
+    // include leading and trailing blank lines
+    "\nHi.\nThis is a test.\nTesting.\n",
+];
+
+const WINDOWS_CASES: [&str; 2] = [
+    // use windows line endings
+    "Hi.\r\nThis is a test.\r\nTesting.",
+    // pathological case
+    "Hi.\r\nThis is a test.\n\r\nTesting.\r\n\n",
+];
+
+#[test]
+fn test_indent_nomargin_default() {
+    // indent should do nothing if 'prefix' is empty.
+    for text in ROUNDTRIP_CASES.iter() {
+        assert_eq!(&indent(text, ""), text);
+    }
+    for text in WINDOWS_CASES.iter() {
+        assert_eq!(&indent(text, ""), text);
+    }
+}
+
+#[test]
+fn test_roundtrip_spaces() {
+    // A whitespace prefix should roundtrip with dedent
+    for text in ROUNDTRIP_CASES.iter() {
+        assert_eq!(&dedent(&indent(text, "    ")), text);
+    }
+}
+
+#[test]
+fn test_roundtrip_tabs() {
+    // A whitespace prefix should roundtrip with dedent
+    for text in ROUNDTRIP_CASES.iter() {
+        assert_eq!(&dedent(&indent(text, "\t\t")), text);
+    }
+}
+
+#[test]
+fn test_roundtrip_mixed() {
+    // A whitespace prefix should roundtrip with dedent
+    for text in ROUNDTRIP_CASES.iter() {
+        assert_eq!(&dedent(&indent(text, " \t  \t ")), text);
+    }
+}
+
+#[test]
+fn test_indent_default() {
+    // Test default indenting of lines that are not whitespace only
+    let prefix = "  ";
+    let expected = [
+        // Basic test case
+        "  Hi.\n  This is a test.\n  Testing.",
+        // Include a blank line
+        "  Hi.\n  This is a test.\n\n  Testing.",
+        // Include leading and trailing blank lines
+        "\n  Hi.\n  This is a test.\n  Testing.\n",
+    ];
+    for (text, expect) in ROUNDTRIP_CASES.iter().zip(expected.iter()) {
+        assert_eq!(&indent(text, prefix), expect)
+    }
+    let expected = [
+        // Use Windows line endings
+        "  Hi.\r\n  This is a test.\r\n  Testing.",
+        // Pathological case
+        "  Hi.\r\n  This is a test.\n\r\n  Testing.\r\n\n",
+    ];
+    for (text, expect) in WINDOWS_CASES.iter().zip(expected.iter()) {
+        assert_eq!(&indent(text, prefix), expect)
+    }
+}
+
+#[test]
+fn indented_text_should_have_the_same_number_of_lines_as_the_original_text() {
+    let texts = ["foo\nbar", "foo\nbar\n", "foo\nbar\nbaz"];
+    for original in texts.iter() {
+        let indented = indent(original, "");
+        assert_eq!(&indented, original);
+    }
+}