Skip to content

Commit 1f0c604

Browse files
authored
Fix source-boundary preprocessing and chunk endings (#1194)
* fix(source): honor bounded input preprocessing Use logical source bounds for BOM, gzip, and reverse footer scans so SubArray and positioned IO inputs do not inspect their parent buffers. Exclude comment rows from footerskip counts as documented, including trailing comment runs. * fix(chunks): preserve final source byte Keep the inclusive end position for the final CSV.Chunks partition. Earlier partitions still exclude the shared boundary byte, matching the established multithreaded CSV.File behavior.
1 parent b9280de commit 1f0c604

6 files changed

Lines changed: 100 additions & 13 deletions

File tree

src/chunks.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ function Base.iterate(x::Chunks, i=1)
9393
names = copy(x.ctx.names)
9494
columns = [Column(col) for col in x.ctx.columns]
9595
datapos = x.ctx.chunkpositions[i]
96-
len = x.ctx.chunkpositions[i + 1] - 1
96+
len = x.ctx.chunkpositions[i + 1] - (i != x.ctx.ntasks)
9797
rowsguess = cld(x.ctx.rowsguess, x.ctx.ntasks)
9898
threaded = false
9999
ntasks = 1

src/context.jl

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ end
386386
throw(ArgumentError("delimited source to parse too large; must be < $MAX_INPUT_SIZE bytes"))
387387
end
388388
# skip over initial BOM character, if present
389-
pos = consumeBOM(buf, pos)
389+
pos = consumeBOM(buf, pos, len)
390390

391391
oq = something(openquotechar, quotechar) % UInt8
392392
eq = escapechar % UInt8
@@ -408,11 +408,23 @@ end
408408
end
409409
cmt = comment === nothing ? nothing : (pointer(comment), sizeof(comment))
410410

411-
if footerskip > 0 && len > 0
412-
lastbyte = buf[end]
411+
if footerskip > 0 && pos <= len
412+
lastbyte = buf[len]
413413
endpos = (lastbyte == UInt8('\r') || lastbyte == UInt8('\n')) +
414-
(lastbyte == UInt8('\n') && buf[end - 1] == UInt8('\r'))
415-
revlen = skiptorow(ReversedBuf(buf), 1 + endpos, len, oq, eq, cq, cmt, ignoreemptyrows, 0, footerskip) - 2
414+
(lastbyte == UInt8('\n') && len > pos && buf[len - 1] == UInt8('\r'))
415+
revbuf = ReversedBuf(buf, pos, len)
416+
revbuflen = length(revbuf)
417+
rows_to_skip = footerskip
418+
revlen = 0
419+
while true
420+
revlen = skiptorow(revbuf, 1 + endpos, revbuflen, oq, eq, cq, nothing, ignoreemptyrows, 0, rows_to_skip) - 2
421+
comments = countcomments(buf, len - revlen + 1, len, oq, eq, cq, cmt)
422+
adjusted_rows_to_skip = footerskip + comments
423+
rows_to_skip == adjusted_rows_to_skip && break
424+
rows_to_skip = adjusted_rows_to_skip > rows_to_skip ?
425+
max(adjusted_rows_to_skip, 2 * rows_to_skip) :
426+
adjusted_rows_to_skip
427+
end
416428
len -= revlen
417429
debug && println("adjusted for footerskip, len = $(len + revlen - 1) => $len")
418430
end

src/detection.jl

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,32 @@ function checkcommentandemptyline(buf, pos, len, @nospecialize(cmt), ignoreempty
320320
return pos
321321
end
322322

323+
function countcomments(buf, pos, len, oq, eq, cq, @nospecialize(cmt))
324+
cmt === nothing && return 0
325+
cmtptr, cmtlen = cmt
326+
comments = 0
327+
while pos <= len
328+
if cmtlen > 0 && (pos + cmtlen - 1) <= len && Parsers.memcmp(pointer(buf, pos), cmtptr, cmtlen)
329+
comments += 1
330+
pos += cmtlen
331+
while pos <= len
332+
@inbounds b = buf[pos]
333+
pos += 1
334+
if b == UInt8('\n')
335+
break
336+
elseif b == UInt8('\r')
337+
pos <= len && buf[pos] == UInt8('\n') && (pos += 1)
338+
break
339+
end
340+
end
341+
else
342+
newpos = skiptorow(buf, pos, len, oq, eq, cq, nothing, false, 0, 1)
343+
pos = newpos > pos ? newpos : len + 1
344+
end
345+
end
346+
return comments
347+
end
348+
323349
struct ColumnProperties
324350
typecode::UInt8
325351
maxstringsize::UInt8

src/utils.jl

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ function chaincolumns!(@nospecialize(a), @nospecialize(b))
243243
end
244244

245245
# one-liner suggested from ScottPJones
246-
consumeBOM(buf, pos) = (length(buf) >= 3 && buf[pos] == 0xef && buf[pos + 1] == 0xbb && buf[pos + 2] == 0xbf) ? pos + 3 : pos
246+
consumeBOM(buf, pos, len) = (len - pos + 1 >= 3 && buf[pos] == 0xef && buf[pos + 1] == 0xbb && buf[pos + 2] == 0xbf) ? pos + 3 : pos
247247

248248
if isdefined(Base, :Memory)
249249
if isdefined(Base,:wrap)
@@ -301,15 +301,16 @@ end
301301

302302
function getsource(@nospecialize(x), buffer_in_memory)
303303
buf, pos, len, tfile = getbytebuffer(x, buffer_in_memory)::Tuple{Vector{UInt8},Int,Int,Union{Nothing,String}}
304-
if length(buf) >= 2 && buf[1] == 0x1f && buf[2] == 0x8b
304+
if len - pos + 1 >= 2 && buf[pos] == 0x1f && buf[pos + 1] == 0x8b
305305
# gzipped source, gunzip it
306+
compressed = pos == 1 && len == length(buf) ? buf : @view(buf[pos:len])
306307
if buffer_in_memory
307-
buf = transcode(GzipDecompressor, buf)
308+
buf = transcode(GzipDecompressor, compressed isa Vector{UInt8} ? compressed : collect(compressed))
308309
else
309310
# 917; if we already buffered input to tempfile, make sure the compressed tempfile is
310311
# cleaned up since we're only passing the *uncompressed* tempfile up for removal post-parsing
311312
tfile1 = tfile === nothing ? nothing : tfile
312-
buf, tfile = buffer_to_tempfile(GzipDecompressor(), IOBuffer(buf))
313+
buf, tfile = buffer_to_tempfile(GzipDecompressor(), IOBuffer(compressed))
313314
if tfile1 !== nothing
314315
rm(tfile1; force=true)
315316
end
@@ -564,12 +565,15 @@ end
564565
# and skips lines backwards
565566
struct ReversedBuf <: AbstractVector{UInt8}
566567
buf::Vector{UInt8}
568+
first::Int
569+
last::Int
567570
end
571+
ReversedBuf(buf::Vector{UInt8}) = ReversedBuf(buf, firstindex(buf), lastindex(buf))
568572

569-
Base.size(a::ReversedBuf) = size(a.buf)
573+
Base.size(a::ReversedBuf) = (a.last - a.first + 1,)
570574
Base.IndexStyle(::Type{ReversedBuf}) = Base.IndexLinear()
571-
Base.getindex(a::ReversedBuf, i::Int) = a.buf[end + 1 - i]
572-
Base.pointer(a::ReversedBuf, pos::Integer=1) = pointer(a.buf, length(a.buf) + 1 - pos)
575+
Base.getindex(a::ReversedBuf, i::Int) = a.buf[a.last + 1 - i]
576+
Base.pointer(a::ReversedBuf, pos::Integer=1) = pointer(a.buf, a.last + 1 - pos)
573577

574578
memset!(ptr, value, num) = ccall(:memset, Ptr{Cvoid}, (Ptr{Cvoid}, Cint, Csize_t), ptr, value, num)
575579

test/basics.jl

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,15 @@ f = CSV.File(IOBuffer("x\r\n1\r\n2\r\n3\r\n4\r\n5\r\n"), footerskip=3)
449449
@test length(f) == 2
450450
@test f[1][1] == 1
451451

452+
# Comment rows do not count towards footerskip.
453+
for newline in ("\n", "\r\n", "\r")
454+
csv = join(("a,b", "1,2", "3,4", "# trailing comment 1", "# trailing comment 2"), newline) * newline
455+
f = CSV.File(IOBuffer(csv); comment="#", footerskip=1)
456+
@test Tables.rowtable(f) == [(a=1, b=2)]
457+
rows = CSV.Rows(IOBuffer(csv); comment="#", footerskip=1, types=Int)
458+
@test Tables.rowtable(rows) == [(a=1, b=2)]
459+
end
460+
452461
# 578
453462
f = CSV.File(IOBuffer("h1234567890123456\t"^2262 * "lasthdr\r\n" * "dummy dummy dummy\r\n" * ("1.23\t"^2262 * "2.46\r\n")^10), skipto=3, ntasks=1);
454463
@test (length(f), length(f.names)) == (10, 2263)
@@ -737,6 +746,37 @@ f = CSV.File(@view(data[:]))
737746
@test length(f) == 2
738747
@test f.column_name == ["foobar", "bazbat"]
739748

749+
# Preprocessing must honor SubArray and IOBuffer source bounds.
750+
data = Vector{UInt8}("a,b\n1,2\n3,4\n")
751+
compressed = transcode(GzipCompressor, data)
752+
prefix = Vector{UInt8}("ignored prefix")
753+
suffix = Vector{UInt8}("ignored suffix")
754+
755+
parent = vcat(prefix, compressed, suffix)
756+
firstbyte = length(prefix) + 1
757+
lastbyte = firstbyte + length(compressed) - 1
758+
for buffer_in_memory in (false, true)
759+
f = CSV.File(@view(parent[firstbyte:lastbyte]); buffer_in_memory)
760+
@test Tables.rowtable(f) == [(a=1, b=2), (a=3, b=4)]
761+
end
762+
763+
io = IOBuffer(vcat(prefix, compressed))
764+
seek(io, length(prefix))
765+
f = CSV.File(io)
766+
@test Tables.rowtable(f) == [(a=1, b=2), (a=3, b=4)]
767+
768+
parent = vcat(compressed, data)
769+
firstbyte = length(compressed) + 1
770+
f = CSV.File(@view(parent[firstbyte:end]))
771+
@test Tables.rowtable(f) == [(a=1, b=2), (a=3, b=4)]
772+
773+
parent = vcat(prefix, data, suffix)
774+
firstbyte = length(prefix) + 1
775+
lastbyte = firstbyte + length(data) - 1
776+
f = CSV.File(@view(parent[firstbyte:lastbyte]); footerskip=1)
777+
@test Tables.rowtable(f) == [(a=1, b=2)]
778+
@test isempty(CSV.File(UInt8[0xef, 0xbb, 0xbf]; footerskip=1))
779+
740780
# 901; nonstandard types passed via types=T
741781
f = CSV.File(IOBuffer("a,b,c\n1.2,3.4,5.6\n"); types=Float32)
742782
@test length(f) == 1

test/runtests.jl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,11 @@ state = iterate(chunks, st)
240240
f, st = state
241241
@test length(f) == 34914
242242

243+
data = Vector{UInt8}("a,b\n" * join(("$(i),value$(i)" for i in 1:40), "\n") * "X")
244+
chunks = collect(CSV.Chunks(data; ntasks=2, rows_to_check=5, pool=false))
245+
@test sum(length, chunks) == 40
246+
@test last(last(chunks)).b == "value40X"
247+
243248
end
244249

245250
function strs(x::Vector, e=nothing)

0 commit comments

Comments
 (0)