#259 Jul 14, 2026

259. str::lines — Split Into Lines Without Dragging \r Along

split('\n') works fine — until a Windows-saved file hands you lines that all end in an invisible \r. lines() was built for exactly this.

The trap

Same story as bite 258: split takes your separator literally. A file saved on Windows uses \r\n line endings, so every “line” keeps a carriage return — and the trailing newline produces a bonus empty string:

1
2
3
4
5
6
7
let text = "alpha\r\nbeta\r\ngamma\r\n";

let naive: Vec<&str> = text.split('\n').collect();
assert_eq!(
    naive,
    ["alpha\r", "beta\r", "gamma\r", ""]
);

That stray \r is invisible in most debug output, so it surfaces as "gamma" != "gamma" mysteries: failed comparisons, HashMap misses, parse errors on the last field.

The fix

lines() splits on \n and strips a trailing \r if one is there — so the same code handles Unix and Windows files:

1
2
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines, ["alpha", "beta", "gamma"]);

No trailing empty string either — like split_terminator (bite 233), the final newline is treated as a terminator, not a separator.

One caveat

Only \r\n and \n count as line endings. A lone \r (classic Mac OS, some protocol payloads) does not split:

1
2
3
let old_mac = "alpha\rbeta";
let lines: Vec<&str> = old_mac.lines().collect();
assert_eq!(lines, ["alpha\rbeta"]);

And a \r in the middle of a line stays untouched — only one directly before the \n is stripped.

Reading a file? BufRead::lines() gives you the same semantics over owned Strings. Either way: for “give me the lines”, it’s lines() every time — save split('\n') for when you truly mean raw bytes-between-newlines.

← Previous 258. split_whitespace — Split on Runs, Not on Every Single Space