Skip to content

Commit 2861815

Browse files
authored
fix(json): preserve floats with trailing zero when encoding YAML to JSON (#2701)
YAML scalars tagged `!!float` were round-tripped through `float64` and re-serialized by Go's JSON encoder, which strips the decimal part of whole-number floats. As a result, `50.0` came out as `50` and a sequence like `[50.0, 95.0, 99.0, 99.9]` became `[50,95,99,99.9]`, turning a uniform array of floats into a mixed int/float array that downstream consumers (Horreum, JSON Schema validators, jq, etc.) reject. The JSON spec does not distinguish ints from floats, but every common JSON library (Go's `encoding/json`, Python's `json`, jq) preserves the fractional form of values that came in as floats. yq's YAML decoder already parses these as `!!float` with the original text intact, so we can emit them verbatim instead of round-tripping. `MarshalJSON` for `ScalarNode` now special-cases `!!float`: - if `Value` is already a JSON-shaped number literal containing a `.` or exponent, emit it verbatim (e.g. `50.0`, `99.9`, `1.5e-3`, `-7.0`); - if `Value` is an integer-shaped string tagged `!!float` (e.g. `!!float 5`), format the parsed float and append `.0` so it stays a JSON number with a fractional part; - otherwise (empty value, parse error, or non-finite result), fall back to the existing encoding path so behaviour for `.inf` / `.nan` and anything unusual is unchanged. `!!int` nodes still encode as JSON integers. Closes #2683 Signed-off-by: ChrisJr404 <chris@hacknow.com>
1 parent fcb7982 commit 2861815

3 files changed

Lines changed: 155 additions & 0 deletions

File tree

pkg/yqlib/candidate_node_json.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import (
77
"errors"
88
"fmt"
99
"io"
10+
"math"
11+
"strconv"
12+
"strings"
1013

1114
"github.com/goccy/go-json"
1215
)
@@ -140,6 +143,12 @@ func (o *CandidateNode) MarshalJSON() ([]byte, error) {
140143
return buf.Bytes(), err
141144
case ScalarNode:
142145
log.Debugf("MarshalJSON ScalarNode")
146+
if o.guessTagFromCustomType() == "!!float" {
147+
if raw, ok := jsonFloatLiteral(o.Value); ok {
148+
buf.WriteString(raw)
149+
return buf.Bytes(), nil
150+
}
151+
}
143152
value, err := o.GetValueRep()
144153
if err != nil {
145154
return buf.Bytes(), err
@@ -177,3 +186,85 @@ func (o *CandidateNode) MarshalJSON() ([]byte, error) {
177186
return buf.Bytes(), err
178187
}
179188
}
189+
190+
// jsonFloatLiteral returns a JSON-shaped representation of a YAML !!float scalar
191+
// value, preserving the original textual form (e.g. "50.0" stays "50.0") whenever
192+
// possible. The second return value is false when the value cannot be safely
193+
// rendered as a JSON number (e.g. ".inf", ".nan", or anything that parses to a
194+
// non-finite float); callers should fall back to the normal encoding path in
195+
// that case, which preserves the existing behaviour for those inputs.
196+
func jsonFloatLiteral(raw string) (string, bool) {
197+
if raw == "" {
198+
return "", false
199+
}
200+
f, err := strconv.ParseFloat(raw, 64)
201+
if err != nil {
202+
return "", false
203+
}
204+
if math.IsInf(f, 0) || math.IsNaN(f) {
205+
return "", false
206+
}
207+
if isJSONNumberLiteral(raw) {
208+
return raw, true
209+
}
210+
formatted := strconv.FormatFloat(f, 'f', -1, 64)
211+
if !strings.ContainsAny(formatted, ".eE") {
212+
formatted += ".0"
213+
}
214+
return formatted, true
215+
}
216+
217+
// isJSONNumberLiteral reports whether s is already a valid JSON number literal
218+
// representing a fractional value (i.e. contains a "." or an exponent), so it
219+
// can be emitted verbatim without round-tripping through a float64.
220+
func isJSONNumberLiteral(s string) bool {
221+
if s == "" {
222+
return false
223+
}
224+
i := 0
225+
if s[i] == '-' {
226+
i++
227+
if i == len(s) {
228+
return false
229+
}
230+
}
231+
// integer part: 0 or [1-9][0-9]*
232+
if s[i] == '0' {
233+
i++
234+
} else if s[i] >= '1' && s[i] <= '9' {
235+
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
236+
i++
237+
}
238+
} else {
239+
return false
240+
}
241+
hasFraction := false
242+
if i < len(s) && s[i] == '.' {
243+
hasFraction = true
244+
i++
245+
if i == len(s) || s[i] < '0' || s[i] > '9' {
246+
return false
247+
}
248+
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
249+
i++
250+
}
251+
}
252+
hasExponent := false
253+
if i < len(s) && (s[i] == 'e' || s[i] == 'E') {
254+
hasExponent = true
255+
i++
256+
if i < len(s) && (s[i] == '+' || s[i] == '-') {
257+
i++
258+
}
259+
if i == len(s) || s[i] < '0' || s[i] > '9' {
260+
return false
261+
}
262+
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
263+
i++
264+
}
265+
}
266+
if i != len(s) {
267+
return false
268+
}
269+
return hasFraction || hasExponent
270+
}

pkg/yqlib/doc/usage/convert.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,22 @@ will output
125125
{"whatever":"cat"}
126126
```
127127

128+
## Encode json: preserve floats with trailing zero
129+
Whole-number floats keep their decimal point so downstream consumers see a JSON number with a fractional part (matches Go's encoding/json, Python's json, and jq).
130+
131+
Given a sample.yml file of:
132+
```yaml
133+
percentiles: [50.0, 95.0, 99.0, 99.9]
134+
```
135+
then
136+
```bash
137+
yq -o=json -I=0 '.' sample.yml
138+
```
139+
will output
140+
```json
141+
{"percentiles":[50.0,95.0,99.0,99.9]}
142+
```
143+
128144
## Roundtrip JSON Lines / NDJSON
129145
Given a sample.json file of:
130146
```json

pkg/yqlib/json_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,54 @@ var jsonScenarios = []formatScenario{
220220
expected: "{\"stuff\":\"cool\"}\n{\"whatever\":\"cat\"}\n",
221221
scenarioType: "encode",
222222
},
223+
{
224+
description: "Encode json: preserve floats with trailing zero",
225+
subdescription: "Whole-number floats keep their decimal point so downstream consumers see a JSON number with a fractional part (matches Go's encoding/json, Python's json, and jq).",
226+
input: `percentiles: [50.0, 95.0, 99.0, 99.9]`,
227+
indent: 0,
228+
expected: "{\"percentiles\":[50.0,95.0,99.0,99.9]}\n",
229+
scenarioType: "encode",
230+
},
231+
{
232+
description: "Encode json: ints stay ints",
233+
skipDoc: true,
234+
input: `a: 50`,
235+
indent: 0,
236+
expected: "{\"a\":50}\n",
237+
scenarioType: "encode",
238+
},
239+
{
240+
description: "Encode json: !!float tagged whole number gets .0",
241+
skipDoc: true,
242+
input: `a: !!float 5`,
243+
indent: 0,
244+
expected: "{\"a\":5.0}\n",
245+
scenarioType: "encode",
246+
},
247+
{
248+
description: "Encode json: scientific notation float preserved",
249+
skipDoc: true,
250+
input: `a: 1.5e-3`,
251+
indent: 0,
252+
expected: "{\"a\":1.5e-3}\n",
253+
scenarioType: "encode",
254+
},
255+
{
256+
description: "Encode json: negative float preserved",
257+
skipDoc: true,
258+
input: `a: -7.0`,
259+
indent: 0,
260+
expected: "{\"a\":-7.0}\n",
261+
scenarioType: "encode",
262+
},
263+
{
264+
description: "Encode json: mixed int and float array",
265+
skipDoc: true,
266+
input: `a: [1, 2.0, 3, 4.5]`,
267+
indent: 0,
268+
expected: "{\"a\":[1,2.0,3,4.5]}\n",
269+
scenarioType: "encode",
270+
},
223271
{
224272
description: "Roundtrip JSON Lines / NDJSON",
225273
input: sampleNdJson,

0 commit comments

Comments
 (0)