Skip to content

Commit a55c30c

Browse files
committed
fix(floats): disambiguate decimal delimiter
Treat a shared decimal and delimiter byte as a delimiter outside quoted fields and as a decimal inside quoted fields. Carry the active delimiter context into numeric parsing. Preserve custom decimal behavior for single-value parsing, BigFloat, and Number across string, byte-buffer, view, and IO sources. Fixes #185
1 parent d1ebaa9 commit a55c30c

5 files changed

Lines changed: 136 additions & 32 deletions

File tree

src/Parsers.jl

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ end
101101
* `closequotechar='"'`: the ascii character that signals the end of a quoted field
102102
* `escapechar='"'`: an ascii character used to "escape" a `closequotechar` within a quoted field
103103
* `delim=','`: if `nothing`, no delimiter will be checked for; if a `Char` or `String`, a delimiter will be checked for directly after parsing a value or `closequotechar`; a newline (`\\n`), return (`\\r`), or CRLF (`"\\r\\n"`) are always considered "delimiters", in addition to EOF
104-
* `decimal='.'`: an ascii character to be used when parsing float values that separates a decimal value
104+
* `decimal='.'`: an ascii character to be used when parsing float values that separates a decimal value. When `decimal` matches `delim`, the character is treated as a delimiter outside quoted values and as a decimal inside quoted values.
105105
* `trues=nothing`: if `nothing`, `Bool` parsing will only check for the string `true` or an `Integer` value of `1` as valid values for `true`; as a `Vector{String}`, each string value will be checked to indicate a valid `true` value
106106
* `falses=nothing`: if `nothing`, `Bool` parsing will only check for the string `false` or an `Integer` value of `0` as valid values for `false`; as a `Vector{String}`, each string value will be checked to indicate a valid `false` value
107107
* `dateformat=nothing`: if `nothing`, `Date`, `DateTime`, and `Time` parsing will use a default `Dates.DateFormat` object while parsing; a `String` or `Dates.DateFormat` object can be provided for custom format parsing
@@ -437,7 +437,7 @@ end
437437

438438
# condensed version of xparse that doesn't worry about quoting or delimiters; called from Parsers.parse/Parsers.tryparse
439439
_xparse2(conf::AbstractConf{T}, source::Union{AbstractVector{UInt8}, IO}, pos, len, opts::Options=OPTIONS, ::Type{S}=returntype(T)) where {T, S} =
440-
Result(whitespace(false, false, false, true)(typeparser(opts)))(conf, source, pos, len, S)
440+
Result(whitespace(false, false, false, true)(typeparser(opts, false)))(conf, source, pos, len, S)
441441

442442
xparse2(::Type{T}, source::SourceType, pos, len, options=OPTIONS, ::Type{S}=returntype(T)) where {T, S} =
443443
result(T, xparse2(conf(T, options), source, pos, len, options, S))
@@ -493,6 +493,13 @@ function _has_groupmark(opts::Options, code::ReturnCode)
493493
return false
494494
end
495495

496+
@inline function _effective_decimal(opts::Options, code::ReturnCode, checkdelim::Bool)
497+
if checkdelim && !quoted(code) && opts.decimal == opts.delim
498+
return 0xff
499+
end
500+
return opts.decimal
501+
end
502+
496503

497504
if isdefined(Base, :OncePerTask)
498505
const _get_bigint = OncePerTask{BigInt}(() -> BigInt(; nbits=256))

src/components.jl

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -376,13 +376,18 @@ function delimiter(checkdelim, delim, ignorerepeated, cmt, ignoreemptylines, str
376376
end
377377
end
378378

379-
function typeparser(opts::Options)
379+
typeparser(opts::Options) = typeparser(opts, opts.flags.checkdelim)
380+
381+
function typeparser(opts::Options, checkdelim::Bool)
380382
function(conf::AbstractConf{T}, source, pos, len, b, code, pl) where {T}
381383
Base.@_inline_meta
382-
return typeparser(conf, source, pos, len, b, code, pl, opts)
384+
return typeparser(conf, source, pos, len, b, code, pl, opts, checkdelim)
383385
end
384386
end
385387

388+
typeparser(conf, source, pos, len, b, code, pl, opts, ::Bool) =
389+
typeparser(conf, source, pos, len, b, code, pl, opts)
390+
386391
# backwards compat
387392
function typeparser(conf, source, pos, len, b, code, opts::Options)
388393
pos, code, pl, x = typeparser(conf, source, pos, len, b, code, poslen(pos, 0), opts)
@@ -395,4 +400,4 @@ function typeparser(::Type{T}, source, pos, len, b, code, opts::Options) where {
395400
end
396401

397402
typeparser(::Type{T}, source, pos, len, b, code, pl) where {T} =
398-
typeparser(DefaultConf{T}(), source, pos, len, b, code, pl, Options())
403+
typeparser(DefaultConf{T}(), source, pos, len, b, code, pl, Options())

src/floats.jl

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -62,22 +62,31 @@ end
6262
return _contiguous(source) && len < length(source) && @inbounds(source[len + 1]) == 0x00
6363
end
6464

65-
@inline function _ends_on_delimiter(source::AbstractVector{UInt8}, len, options)
65+
@inline function _ends_on_delimiter(source::AbstractVector{UInt8}, len, options, checkdelim)
6666
0 < len <= length(source) || return false
6767
@inbounds b = source[len]
68-
return b == UInt8('\n') || b == UInt8('\r') || (options.flags.checkdelim && options.delim == b)
68+
return b == UInt8('\n') || b == UInt8('\r') || (checkdelim && options.delim == b)
6969
end
7070

71-
function _bigfloat_buffer(source::AbstractVector{UInt8}, pos, len, b, code, options)
72-
_, _, pl, _ = typeparser(DefaultConf{String}(), source, pos, len, b, code, poslen(pos, 0), options)
71+
function _bigfloat_poslen(source, pos, len, b, code, options, checkdelim)
72+
if quoted(code) || checkdelim
73+
_, _, pl, _ = typeparser(DefaultConf{String}(), source, pos, len, b, code, poslen(pos, 0), options)
74+
else
75+
_, _, pl, _ = findeof(source, pos, len, b, code, poslen(pos, 0), options)
76+
end
77+
return pl
78+
end
79+
80+
function _bigfloat_buffer(source::AbstractVector{UInt8}, pos, len, b, code, options, checkdelim)
81+
pl = _bigfloat_poslen(source, pos, len, b, code, options, checkdelim)
7382
return _copy_nulterminated(source, pl.pos, pl.len)
7483
end
7584

76-
_bigfloat_buffer(source::AbstractString, pos, len, b, code, options) =
77-
_bigfloat_buffer(codeunits(source), pos, len, b, code, options)
85+
_bigfloat_buffer(source::AbstractString, pos, len, b, code, options, checkdelim) =
86+
_bigfloat_buffer(codeunits(source), pos, len, b, code, options, checkdelim)
7887

79-
function _bigfloat_buffer(source::IO, pos, len, b, code, options)
80-
_, _, pl, _ = typeparser(DefaultConf{String}(), source, pos, len, b, code, poslen(pos, 0), options)
88+
function _bigfloat_buffer(source::IO, pos, len, b, code, options, checkdelim)
89+
pl = _bigfloat_poslen(source, pos, len, b, code, options, checkdelim)
8190
vlen = max(0, pl.len)
8291
buf = Vector{UInt8}(undef, vlen + 1)
8392
fastseek!(source, pl.pos - 1)
@@ -87,6 +96,13 @@ function _bigfloat_buffer(source::IO, pos, len, b, code, options)
8796
return buf
8897
end
8998

99+
function _normalize_decimal!(buf, decimal)
100+
@inbounds for i = 1:(length(buf) - 1)
101+
buf[i] == decimal && (buf[i] = UInt8('.'))
102+
end
103+
return buf
104+
end
105+
90106
@inline function _mpfr_strtofr(z, ptr, endptr, base, rounding)
91107
return ccall((:mpfr_strtofr, :libmpfr), Int32, (Ref{BigFloat}, Cstring, Ref{Ptr{UInt8}}, Int32, Base.MPFR.MPFRRoundingMode), z, ptr, endptr, base, rounding)
92108
end
@@ -100,19 +116,30 @@ end
100116
return pos, code, PosLen(pl.pos, max(0, pos - pl.pos)), z
101117
end
102118

103-
function typeparser(::AbstractConf{BigFloat}, source, pos, len, b, code, pl, options)
119+
function typeparser(conf::AbstractConf{BigFloat}, source, pos, len, b, code, pl, options)
120+
return typeparser(conf, source, pos, len, b, code, pl, options, options.flags.checkdelim)
121+
end
122+
123+
function typeparser(::AbstractConf{BigFloat}, source, pos, len, b, code, pl, options, checkdelim::Bool)
104124
base = 0
105125
rounding = Base.MPFR.ROUNDING_MODE[]
106126
z = BigFloat(precision=Base.MPFR.DEFAULT_PRECISION[])
107-
if _nulterminated(source, len)
127+
decimal = _effective_decimal(options, code, checkdelim)
128+
if decimal != UInt8('.')
129+
buf = _bigfloat_buffer(source, pos, len, b, code, options, checkdelim)
130+
decimal != 0xff && _normalize_decimal!(buf, decimal)
131+
GC.@preserve buf begin
132+
return _finish_bigfloat(source, pos, code, pl, z, pointer(buf), base, rounding)
133+
end
134+
elseif _nulterminated(source, len)
108135
GC.@preserve source begin
109136
return _finish_bigfloat(source, pos, code, pl, z, pointer(source, pos), base, rounding)
110137
end
111138
elseif _writable_contiguous(source)
112-
nulpos = if _ends_on_delimiter(source, len, options)
139+
nulpos = if _ends_on_delimiter(source, len, options, checkdelim)
113140
len
114141
else
115-
_, _, strpl, _ = typeparser(DefaultConf{String}(), source, pos, len, b, code, poslen(pos, 0), options)
142+
strpl = _bigfloat_poslen(source, pos, len, b, code, options, checkdelim)
116143
strpl.pos + strpl.len
117144
end
118145
if nulpos <= length(source)
@@ -126,24 +153,29 @@ function typeparser(::AbstractConf{BigFloat}, source, pos, len, b, code, pl, opt
126153
@inbounds source[nulpos] = byte
127154
end
128155
else
129-
_, _, strpl, _ = typeparser(DefaultConf{String}(), source, pos, len, b, code, poslen(pos, 0), options)
156+
strpl = _bigfloat_poslen(source, pos, len, b, code, options, checkdelim)
130157
buf = _copy_nulterminated(source, strpl.pos, strpl.len)
131158
GC.@preserve buf begin
132159
return _finish_bigfloat(source, pos, code, pl, z, pointer(buf), base, rounding)
133160
end
134161
end
135162
else
136-
buf = _bigfloat_buffer(source, pos, len, b, code, options)
163+
buf = _bigfloat_buffer(source, pos, len, b, code, options, checkdelim)
137164
GC.@preserve buf begin
138165
return _finish_bigfloat(source, pos, code, pl, z, pointer(buf), base, rounding)
139166
end
140167
end
141168
end
142169

143170
@inline function typeparser(conf::AbstractConf{T}, source, pos, len, b, code, pl, options) where {T <: SupportedFloats}
171+
return typeparser(conf, source, pos, len, b, code, pl, options, options.flags.checkdelim)
172+
end
173+
174+
@inline function typeparser(conf::AbstractConf{T}, source, pos, len, b, code, pl, options, checkdelim::Bool) where {T <: SupportedFloats}
144175
# keep track of starting pos in case of invalid, we can rewind to start of parsing
145176
startpos = pos
146177
x = zero(T)
178+
decimal = _effective_decimal(options, code, checkdelim)
147179
neg = b == UInt8('-')
148180
if neg || b == UInt8('+')
149181
pos += 1
@@ -155,7 +187,7 @@ end
155187
@goto done
156188
end
157189
b = peekbyte(source, pos)
158-
if b != options.decimal && (b - UInt8('0')) > 0x09
190+
if b != decimal && (b - UInt8('0')) > 0x09
159191
# character isn't a digit or decimal point, check for special values, otherwise INVALID
160192
if b == UInt8('n') || b == UInt8('N')
161193
pos += 1
@@ -267,7 +299,7 @@ end
267299
end
268300

269301
# start parsing digits or decimal point; we start digits as UInt64(0) and can _widen type if needed
270-
x, code, pos = parsedigits(conf, source, pos, len, b, code, options, UInt64(0), neg, startpos)
302+
x, code, pos = parsedigits(conf, source, pos, len, b, code, options, decimal, UInt64(0), neg, startpos)
271303
if !isfinite(x)
272304
code |= SPECIAL_VALUE
273305
end
@@ -302,24 +334,24 @@ getx(x, f) = f === nothing ? x : nothing
302334
# statically compiled (`juliac --trim`) binary the widened instance doesn't exist, so
303335
# parsing a wide-digit float would fail at runtime. The `@noinline` wrappers keep each
304336
# ladder step compiling separately, which is what bounds base-case compilation.
305-
@noinline _parsedigits(conf::AbstractConf{T}, source, pos, len, b, code, options, digits::IntType, neg::Bool, startpos, overflow_invalid::Bool, ndigits::Int, f::F) where {T, IntType, F} =
306-
parsedigits(conf, source, pos, len, b, code, options, digits, neg, startpos, overflow_invalid, ndigits, f)::Tuple{rettype(T), ReturnCode, Int}
337+
@noinline _parsedigits(conf::AbstractConf{T}, source, pos, len, b, code, options, decimal::UInt8, digits::IntType, neg::Bool, startpos, overflow_invalid::Bool, ndigits::Int, f::F) where {T, IntType, F} =
338+
parsedigits(conf, source, pos, len, b, code, options, decimal, digits, neg, startpos, overflow_invalid, ndigits, f)::Tuple{rettype(T), ReturnCode, Int}
307339

308-
@inline function parsedigits(conf::AbstractConf{T}, source, pos, len, b, code, options, digits::IntType, neg::Bool, startpos, overflow_invalid::Bool=false, ndigits::Int=0, f::F=nothing) where {T, IntType, F}
340+
@inline function parsedigits(conf::AbstractConf{T}, source, pos, len, b, code, options, decimal::UInt8, digits::IntType, neg::Bool, startpos, overflow_invalid::Bool=false, ndigits::Int=0, f::F=nothing) where {T, IntType, F}
309341
x = zero(T)
310342
anydigits = false
311343
has_groupmark = _has_groupmark(options, code)
312344
groupmark0 = something(options.groupmark, 0xff) - UInt8('0')
313345

314346
# we already previously checked if `b` was decimal or a digit, so don't need to check explicitly again
315-
if b != options.decimal
347+
if b != decimal
316348
b -= UInt8('0')
317349
prev_b0 = b
318350
anydigits = b <= 0x09
319351
while true
320352
if b <= 0x09
321353
if overflows(IntType) && digits > overflowval(IntType)
322-
return _parsedigits(conf, source, pos, len, b + UInt8('0'), code, options, _widen(digits), neg, startpos, overflow_invalid, ndigits, f)::Tuple{rettype(T), ReturnCode, Int}
354+
return _parsedigits(conf, source, pos, len, b + UInt8('0'), code, options, decimal, _widen(digits), neg, startpos, overflow_invalid, ndigits, f)::Tuple{rettype(T), ReturnCode, Int}
323355
elseif ndigits > maxdigits(T)
324356
# if input is way too big, just bail
325357
fastseek!(source, startpos - 1)
@@ -361,7 +393,7 @@ getx(x, f) = f === nothing ? x : nothing
361393
# b wasn't a digit, so add back '0' to recover original Char value
362394
b += UInt8('0')
363395
end
364-
if b == options.decimal
396+
if b == decimal
365397
pos += 1
366398
incr!(source)
367399
if eof(source, pos, len)

src/ints.jl

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,23 @@ overflowval(::Type{T}) where {T <: Integer} = div(typemax(T) - T(9), T(10))
9898
return pos, code, PosLen(pl.pos, pos - pl.pos), x
9999
end
100100

101-
function typeparser(::AbstractConf{Number}, source, pos, len, b, code, pl, opts)
101+
function typeparser(conf::AbstractConf{Number}, source, pos, len, b, code, pl, opts)
102+
return typeparser(conf, source, pos, len, b, code, pl, opts, opts.flags.checkdelim)
103+
end
104+
105+
function typeparser(::AbstractConf{Number}, source, pos, len, b, code, pl, opts, checkdelim::Bool)
102106
x = Ref{Number}()
103-
pos, code = parsenumber(source, pos, len, b, y -> (x[] = y), opts)
107+
pos, code = parsenumber(source, pos, len, b, code, y -> (x[] = y), opts, checkdelim)
104108
return pos, code, PosLen(pl.pos, pos - pl.pos), isdefined(x, :x) ? x[] : (0::Number)
105109
end
106110

107111
function parsenumber(source, pos, len, b, f::F, opts=OPTIONS) where {F}
112+
return parsenumber(source, pos, len, b, SUCCESS, f, opts, opts.flags.checkdelim)
113+
end
114+
115+
function parsenumber(source, pos, len, b, code, f::F, opts, checkdelim::Bool) where {F}
108116
startpos = pos
109-
code = startcode = SUCCESS
117+
startcode = code
110118
# begin parsing
111119
neg = b == UInt8('-')
112120
if neg || b == UInt8('+')
@@ -119,11 +127,12 @@ function parsenumber(source, pos, len, b, f::F, opts=OPTIONS) where {F}
119127
end
120128
b = peekbyte(source, pos)
121129
# parse rest of number
122-
_, code, pos = parsedigits(DefaultConf{Number}(), source, pos, len, b, code, OPTIONS, Int64(0), neg, startpos, true, 0, f)
130+
decimal = _effective_decimal(opts, code, checkdelim)
131+
_, code, pos = parsedigits(DefaultConf{Number}(), source, pos, len, b, code, opts, decimal, Int64(0), neg, startpos, true, 0, f)
123132
if invalid(code)
124133
# by default, parsedigits only has up to Float64 precision; if we overflow
125134
# let's try BigFloat
126-
pos, code, _, x = typeparser(DefaultConf{BigFloat}(), source, startpos, len, b, startcode, poslen(pos, 0), opts)
135+
pos, code, _, x = typeparser(DefaultConf{BigFloat}(), source, startpos, len, b, startcode, poslen(pos, 0), opts, checkdelim)
127136
if ok(code)
128137
f(x)
129138
end

test/floats.jl

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,57 @@ end
479479
end
480480
end
481481

482+
@testset "decimal matches delimiter" begin
483+
source_fns = (
484+
identity,
485+
s -> SubString("_" * s, 2),
486+
s -> collect(codeunits(s)),
487+
s -> view(collect(codeunits(s)), :),
488+
IOBuffer,
489+
)
490+
491+
for T in (Float16, Float32, Float64, BigFloat, Number)
492+
expected_decimal = T === BigFloat ? BigFloat("1.2") : T === Number ? 1.2 : T(1.2)
493+
for decimal in (',', '.')
494+
unquoted = "1$(decimal)2$(decimal)3"
495+
quoted = "\"1$(decimal)2\"$(decimal)3"
496+
for make_source in source_fns
497+
res = Parsers.xparse(T, make_source(unquoted); decimal=decimal, delim=decimal)
498+
@test Parsers.ok(res.code)
499+
@test Parsers.delimited(res.code)
500+
@test !Parsers.quoted(res.code)
501+
@test res.tlen == 2
502+
@test res.val == one(T)
503+
504+
res = Parsers.xparse(T, make_source(quoted); decimal=decimal, delim=decimal)
505+
@test Parsers.ok(res.code)
506+
@test Parsers.delimited(res.code)
507+
@test Parsers.quoted(res.code)
508+
@test res.tlen == 6
509+
@test res.val == expected_decimal
510+
end
511+
end
512+
513+
for make_source in source_fns
514+
res = Parsers.xparse(T, make_source("1,2;3"); decimal=',', delim=';')
515+
@test Parsers.ok(res.code)
516+
@test Parsers.delimited(res.code)
517+
@test res.tlen == 4
518+
@test res.val == expected_decimal
519+
end
520+
521+
options = Parsers.Options(decimal=',')
522+
@test Parsers.parse(T, "1,2", options) == expected_decimal
523+
@test Parsers.parse(T, collect(codeunits("1,2")), options) == expected_decimal
524+
@test Parsers.parse(T, IOBuffer("1,2"), options) == expected_decimal
525+
end
526+
527+
bytes = collect(codeunits("1,2;3"))
528+
Parsers.xparse(BigFloat, bytes; decimal=',', delim=';')
529+
@test bytes == codeunits("1,2;3")
530+
@test Parsers.parse(BigFloat, collect(codeunits("1.")), Parsers.Options(delim='.')) == BigFloat(1)
531+
end
532+
482533
@testset "BigFloats" begin
483534
res = Parsers.xparse(BigFloat, Vector(codeunits("1")), 1, 1)
484535
@test res.val == BigFloat(1)

0 commit comments

Comments
 (0)