diff --git a/Project.toml b/Project.toml index 2a631187..de0e3b26 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "JSON" uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -version = "1.7.0" +version = "1.7.1" [deps] Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" @@ -22,7 +22,7 @@ Arrow = "2.8.0" ArrowTypes = "2.2" Parsers = "1, 2" PrecompileTools = "1" -StructUtils = "2.8.3" +StructUtils = "2.8.4" julia = "1.9" [extras] diff --git a/src/write.jl b/src/write.jl index 5262175a..8e84ce17 100644 --- a/src/write.jl +++ b/src/write.jl @@ -31,9 +31,103 @@ sizeguess(_) = 512 const StringLike = Union{Enum, AbstractChar, VersionNumber, Cstring, Cwstring, UUID, Dates.TimeType, Type, Logging.LogLevel} +# Date, DateTime, and Time serialize as exactly `string(x)`, but through +# fixed-format printers instead of the Dates format machinery: DateFormat +# token handling and its diagnostics are too dynamic for static compilation, +# so JSON output of temporal fields would otherwise fail `juliac --trim` +# verification. +@inline function _writetemporal!(buf::Vector{UInt8}, i::Int, value::Integer, width::Int) + for offset in (width - 1):-1:0 + buf[i + offset] = UInt8('0') + UInt8(value % 10) + value = divrem(value, 10)[1] + end + return i + width +end + +function _datestring!(buf::Vector{UInt8}, i::Int, x::Dates.Date) + y = Dates.year(x) + if y < 0 + buf[i] = UInt8('-') + i += 1 + y = -y + end + i = _writetemporal!(buf, i, y, max(4, ndigits(y))) + buf[i] = UInt8('-') + i = _writetemporal!(buf, i + 1, Dates.month(x), 2) + buf[i] = UInt8('-') + return _writetemporal!(buf, i + 1, Dates.day(x), 2) +end + +function _lowerdate(x::Dates.Date) + y = Dates.year(x) + buf = Vector{UInt8}(undef, 6 + max(4, ndigits(abs(y))) + (y < 0 ? 1 : 0)) + _datestring!(buf, 1, x) + return String(buf) +end + +function _timestring!(buf::Vector{UInt8}, i::Int, x::Dates.Time) + i = _writetemporal!(buf, i, Dates.hour(x), 2) + buf[i] = UInt8(':') + i = _writetemporal!(buf, i + 1, Dates.minute(x), 2) + buf[i] = UInt8(':') + i = _writetemporal!(buf, i + 1, Dates.second(x), 2) + # `string(::Time)` prints the shortest subsecond field that loses + # nothing — milliseconds, microseconds, or nanoseconds — and then strips + # the fraction's trailing zeros. + ms, us, ns = Dates.millisecond(x), Dates.microsecond(x), Dates.nanosecond(x) + if ns != 0 + fraction, width = ms * 1_000_000 + us * 1_000 + ns, 9 + elseif us != 0 + fraction, width = ms * 1_000 + us, 6 + elseif ms != 0 + fraction, width = ms, 3 + else + return i + end + while fraction % 10 == 0 + fraction = divrem(fraction, 10)[1] + width -= 1 + end + buf[i] = UInt8('.') + return _writetemporal!(buf, i + 1, fraction, width) +end + +function _lowertime(x::Dates.Time) + n = Dates.nanosecond(x) != 0 ? 18 : + Dates.microsecond(x) != 0 ? 15 : + Dates.millisecond(x) != 0 ? 12 : 8 + buf = Vector{UInt8}(undef, n) + return String(resize!(buf, _timestring!(buf, 1, x) - 1)) +end + +function _lowerdatetime(x::Dates.DateTime) + d = Dates.Date(x) + y = Dates.year(d) + ms = Dates.millisecond(x) + n = 6 + max(4, ndigits(abs(y))) + (y < 0 ? 1 : 0) + 9 + (ms != 0 ? 4 : 0) + buf = Vector{UInt8}(undef, n) + i = _datestring!(buf, 1, d) + buf[i] = UInt8('T') + i = _writetemporal!(buf, i + 1, Dates.hour(x), 2) + buf[i] = UInt8(':') + i = _writetemporal!(buf, i + 1, Dates.minute(x), 2) + buf[i] = UInt8(':') + i = _writetemporal!(buf, i + 1, Dates.second(x), 2) + # `string(::DateTime)` omits the fraction entirely at zero milliseconds + # and always prints three digits otherwise. + if ms != 0 + buf[i] = UInt8('.') + _writetemporal!(buf, i + 1, ms, 3) + end + return String(buf) +end + StructUtils.lower(::JSONStyle, ::Missing) = nothing StructUtils.lower(::JSONStyle, x::Symbol) = String(x) StructUtils.lower(::JSONStyle, x::StringLike) = string(x) +StructUtils.lower(::JSONStyle, x::Dates.Date) = _lowerdate(x) +StructUtils.lower(::JSONStyle, x::Dates.Time) = _lowertime(x) +StructUtils.lower(::JSONStyle, x::Dates.DateTime) = _lowerdatetime(x) StructUtils.lower(::JSONStyle, x::Regex) = x.pattern StructUtils.lower(::JSONStyle, x::Complex) = (re=real(x), im=imag(x)) StructUtils.lower(::JSONStyle, x::AbstractArray{<:Any,0}) = x[1] diff --git a/test/json_trim_public_entrypoints.jl b/test/json_trim_public_entrypoints.jl index 20762a4c..eb5bd0f9 100644 --- a/test/json_trim_public_entrypoints.jl +++ b/test/json_trim_public_entrypoints.jl @@ -1,4 +1,4 @@ -using JSON +using Dates, JSON const ARRAY_JSON = "[1,2,3]" const STRING_JSON = "\"Ada\"" @@ -31,6 +31,12 @@ JSON.StructUtils.@noarg mutable struct TrimMutable value::Int = 0 end +struct TrimTemporal + day::Dates.Date + stamp::Dates.DateTime + tick::Dates.Time +end + function checked(cond::Bool, msg::String)::Nothing cond || error(msg) return nothing @@ -110,6 +116,27 @@ function exercise_write_entrypoints()::Nothing checked(JSON.json(JSON.JSONText("{\"raw\":true}")) == "{\"raw\":true}", "JSONText write failed") checked(JSON.json(JSON.Null()) == "null", "JSON.Null write failed") checked(JSON.json(TrimCode("beta")) == "\"beta\"", "custom lower write failed") + + # Date, DateTime, and Time round-trip through their canonical ISO text. + temporal = TrimTemporal( + Dates.Date(2026, 8, 7), + Dates.DateTime(2026, 8, 7, 15, 0, 0, 76), + Dates.Time(12, 30, 15, 250), + ) + temporal_json = JSON.json(temporal) + checked( + temporal_json == + "{\"day\":\"2026-08-07\",\"stamp\":\"2026-08-07T15:00:00.076\",\"tick\":\"12:30:15.25\"}", + "temporal write failed", + ) + checked( + JSON.parse(temporal_json, TrimTemporal) == temporal, + "temporal read failed", + ) + checked( + JSON.json(Dates.DateTime(2026, 1, 1)) == "\"2026-01-01T00:00:00\"", + "zero-millisecond DateTime write failed", + ) return nothing end diff --git a/test/trim/Project.toml b/test/trim/Project.toml index 70d591cc..f79c77eb 100644 --- a/test/trim/Project.toml +++ b/test/trim/Project.toml @@ -1,3 +1,7 @@ [deps] JuliaC = "acedd4c2-ced6-4a15-accc-2607eb759ba2" Parsers = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" +StructUtils = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" + +[compat] +StructUtils = "2.8.4"