Fix File.Stream Enumerable.count for files without trailing newline (#15147)

The optimized count implementation was counting newline characters
instead of actual lines, returning incorrect results for files that
don't end with a newline (which is common for source code files).

For example, a file with content "line1\nline2\nline3" (3 lines)
was returning 2 instead of 3.

The fix tracks the last byte read and adds 1 at EOF if the file
has content but doesn't end with a newline.
This commit is contained in:
Panagiotis Nezis
2026-03-07 11:54:01 +01:00
committed by GitHub
parent d61ba915b0
commit bbe700151e
2 changed files with 41 additions and 6 deletions
+13 -6
View File
@@ -119,7 +119,7 @@ defmodule File.Stream do
counter = fn device ->
device = skip_bom_and_offset(device, raw, modes)
count_lines(device, path, pattern, read_function(stream), 0)
count_lines(device, path, pattern, read_function(stream), 0, :empty)
end
{:ok, open!(stream, modes, counter)}
@@ -229,21 +229,28 @@ defmodule File.Stream do
for mode <- modes, mode not in [:write, :append, :trim_bom], do: mode
end
defp count_lines(device, path, pattern, read, count) do
defp count_lines(device, path, pattern, read, count, last_byte) do
case read.(device) do
data when is_binary(data) and byte_size(data) > 0 ->
newlines = length(:binary.matches(data, pattern))
last = :binary.last(data)
count_lines(device, path, pattern, read, count + newlines, last)
data when is_binary(data) ->
count_lines(device, path, pattern, read, count + count_lines(data, pattern))
count_lines(device, path, pattern, read, count, last_byte)
:eof ->
count
case last_byte do
:empty -> 0
?\n -> count
_ -> count + 1
end
{:error, reason} ->
raise File.Error, reason: reason, action: "stream", path: path
end
end
defp count_lines(data, pattern), do: length(:binary.matches(data, pattern))
defp read_function(%{raw: true}), do: &IO.binread(&1, @read_ahead_size)
defp read_function(%{raw: false}), do: &IO.read(&1, @read_ahead_size)
end
@@ -65,6 +65,34 @@ defmodule File.StreamTest do
assert Enum.count(stream) == 2
end
test "counts lines without trailing newline" do
no_trailing = tmp_path("no_trailing.txt")
single_line = tmp_path("single_line.txt")
empty_file = tmp_path("empty.txt")
try do
File.write!(no_trailing, "line1\nline2\nline3")
File.write!(single_line, "hello")
File.write!(empty_file, "")
# 3 lines, no trailing newline
stream = stream!(@node, no_trailing)
assert Enum.count(stream) == 3
# 1 line, no newline at all
stream = stream!(@node, single_line)
assert Enum.count(stream) == 1
# empty file
stream = stream!(@node, empty_file)
assert Enum.count(stream) == 0
after
File.rm(no_trailing)
File.rm(single_line)
File.rm(empty_file)
end
end
test "reads and writes lines" do
src = fixture_path("file.txt")
dest = tmp_path("tmp_test.txt")