Skip to content

Commit

Permalink
feat: Preserve numeric types when reading from .chezmoidata JSON and …
Browse files Browse the repository at this point in the history
…JSONC files
  • Loading branch information
twpayne committed Nov 26, 2023
1 parent c25bd06 commit 694977b
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 71 deletions.
74 changes: 72 additions & 2 deletions internal/chezmoi/format.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package chezmoi

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"

"github.com/pelletier/go-toml/v2"
Expand All @@ -18,6 +21,8 @@ var (
FormatYAML Format = formatYAML{}
)

var errExpectedEOF = errors.New("expected EOF")

// A Format is a serialization format.
type Format interface {
Marshal(value any) ([]byte, error)
Expand Down Expand Up @@ -79,7 +84,7 @@ func (formatJSONC) Unmarshal(data []byte, value any) error {
if err != nil {
return err
}
return json.Unmarshal(data, value)
return FormatJSON.Unmarshal(data, value)
}

// Marshal implements Format.Marshal.
Expand All @@ -101,7 +106,32 @@ func (formatJSON) Name() string {

// Unmarshal implements Format.Unmarshal.
func (formatJSON) Unmarshal(data []byte, value any) error {
return json.Unmarshal(data, value)
switch value := value.(type) {
case *[]any:
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
if err := decoder.Decode(value); err != nil {
return err
}
if _, err := decoder.Token(); !errors.Is(err, io.EOF) {
return errExpectedEOF
}
*value = replaceJSONNumbersWithNumericValuesSlice(*value)
return nil
case *map[string]any:
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
if err := decoder.Decode(value); err != nil {
return err
}
if _, err := decoder.Token(); !errors.Is(err, io.EOF) {
return errExpectedEOF
}
*value = replaceJSONNumbersWithNumericValuesMap(*value)
return nil
default:
return json.Unmarshal(data, value)
}
}

// Marshal implements Format.Marshal.
Expand Down Expand Up @@ -169,3 +199,43 @@ func isPrefixDotFormatDotTmpl(name, prefix string) bool {
}
return false
}

// replaceJSONNumbersWithNumericValues replaces any json.Numbers in value with
// int64s or float64s if possible and returns the new value. If value is a slice
// or a map then it is mutated in place.
func replaceJSONNumbersWithNumericValues(value any) any {
switch value := value.(type) {
case json.Number:
if int64Value, err := value.Int64(); err == nil {
return int64Value
}
if float64Value, err := value.Float64(); err == nil {
return float64Value
}
// If value cannot be represented as an int64 or a float64 then return
// it as a string to preserve its value. Such values are valid JSON but
// are unlikely to occur in practice. See
// https://www.rfc-editor.org/rfc/rfc7159#section-6.
return value.String()
case []any:
return replaceJSONNumbersWithNumericValuesSlice(value)
case map[string]any:
return replaceJSONNumbersWithNumericValuesMap(value)
default:
return value
}
}

func replaceJSONNumbersWithNumericValuesMap(value map[string]any) map[string]any {
for k, v := range value {
value[k] = replaceJSONNumbersWithNumericValues(v)
}
return value
}

func replaceJSONNumbersWithNumericValuesSlice(value []any) []any {
for i, e := range value {
value[i] = replaceJSONNumbersWithNumericValues(e)
}
return value
}
7 changes: 7 additions & 0 deletions internal/chezmoi/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import (
"github.com/alecthomas/assert/v2"
)

func TestFormatJSONSingleValue(t *testing.T) {
var value any
assert.NoError(t, FormatJSON.Unmarshal([]byte(`{}`), &value))
assert.NoError(t, FormatJSON.Unmarshal([]byte(`{} `), &value))
assert.Error(t, FormatJSON.Unmarshal([]byte(`{} 1`), &value))
}

func TestFormats(t *testing.T) {
assert.NotZero(t, FormatsByName["json"])
assert.NotZero(t, FormatsByName["jsonc"])
Expand Down
68 changes: 13 additions & 55 deletions internal/cmd/templatefuncs.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package cmd

import (
"bytes"
"encoding/hex"
"encoding/json"
"errors"
Expand All @@ -18,7 +17,6 @@ import (

"github.com/bradenhilton/mozillainstallhash"
"github.com/itchyny/gojq"
"github.com/tailscale/hujson"
"golang.org/x/exp/constraints"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
Expand Down Expand Up @@ -182,45 +180,37 @@ func (c *Config) fromIniTemplateFunc(s string) map[string]any {
//
//nolint:revive,stylecheck
func (c *Config) fromJsonTemplateFunc(s string) any {
decoder := json.NewDecoder(bytes.NewBufferString(s))
decoder.UseNumber()
var data any
if err := decoder.Decode(&data); err != nil {
return err
var value map[string]any
if err := chezmoi.FormatJSON.Unmarshal([]byte(s), &value); err != nil {
panic(err)
}
return replaceJSONNumbersWithNumericValues(data)
return value
}

// fromJsoncTemplateFunc parses s as JSONC and returns the result. In contrast
// to encoding/json, numbers are represented as int64s or float64s if possible.
func (c *Config) fromJsoncTemplateFunc(s string) any {
jsonData, err := hujson.Standardize([]byte(s))
if err != nil {
var value map[string]any
if err := chezmoi.FormatJSONC.Unmarshal([]byte(s), &value); err != nil {
panic(err)
}
decoder := json.NewDecoder(bytes.NewBuffer(jsonData))
decoder.UseNumber()
var data any
if err := decoder.Decode(&data); err != nil {
return err
}
return replaceJSONNumbersWithNumericValues(data)
return value
}

func (c *Config) fromTomlTemplateFunc(s string) any {
var data any
if err := chezmoi.FormatTOML.Unmarshal([]byte(s), &data); err != nil {
var value map[string]any
if err := chezmoi.FormatTOML.Unmarshal([]byte(s), &value); err != nil {
panic(err)
}
return data
return value
}

func (c *Config) fromYamlTemplateFunc(s string) any {
var data any
if err := chezmoi.FormatYAML.Unmarshal([]byte(s), &data); err != nil {
var value map[string]any
if err := chezmoi.FormatYAML.Unmarshal([]byte(s), &value); err != nil {
panic(err)
}
return data
return value
}

func (c *Config) globTemplateFunc(pattern string) []string {
Expand Down Expand Up @@ -745,38 +735,6 @@ func pruneEmptyMaps(m map[string]any) bool {
return len(m) == 0
}

// replaceJSONNumbersWithNumericValues replaces any json.Numbers in value with
// int64s or float64s if possible and returns the new value. If value is a slice
// or a map then it is mutated in place.
func replaceJSONNumbersWithNumericValues(value any) any {
switch value := value.(type) {
case json.Number:
if int64Value, err := value.Int64(); err == nil {
return int64Value
}
if float64Value, err := value.Float64(); err == nil {
return float64Value
}
// If value cannot be represented as an int64 or a float64 then return
// it as a string to preserve its value. Such values are valid JSON but
// are unlikely to occur in practice. See
// https://www.rfc-editor.org/rfc/rfc7159#section-6.
return value.String()
case []any:
for i, e := range value {
value[i] = replaceJSONNumbersWithNumericValues(e)
}
return value
case map[string]any:
for k, v := range value {
value[k] = replaceJSONNumbersWithNumericValues(v)
}
return value
default:
return value
}
}

func sortedKeys[K constraints.Ordered, V any](m map[K]V) []K {
keys := maps.Keys(m)
slices.Sort(keys)
Expand Down
30 changes: 16 additions & 14 deletions internal/cmd/templatefuncs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,36 +162,38 @@ func TestDeleteValueAtPathTemplateFunc(t *testing.T) {
func TestFromJson(t *testing.T) {
c, err := newConfig()
assert.NoError(t, err)
for _, tc := range []struct {
for i, tc := range []struct {
s string
expected any
}{
{
s: "1",
expected: 1,
s: `{"key":1}`,
expected: map[string]any{"key": int64(1)},
},
{
s: "2.2",
expected: 2.2,
s: `{"key":2.2}`,
expected: map[string]any{"key": 2.2},
},
{
s: "[1,2.2,3]",
expected: []any{int64(1), 2.2, int64(3)},
s: `{"key":[1,2.2,3]}`,
expected: map[string]any{"key": []any{int64(1), 2.2, int64(3)}},
},
{
s: `{"k":1}`,
expected: map[string]any{"k": int64(1)},
s: `{"key":1}`,
expected: map[string]any{"key": int64(1)},
},
{
s: "1E400",
expected: "1E400",
s: `{"key":1E400}`,
expected: map[string]any{"key": "1E400"},
},
{
s: "3.141592653589793238462643383279",
expected: 3.141592653589793238462643383279,
s: `{"key":3.141592653589793238462643383279}`,
expected: map[string]any{"key": 3.141592653589793238462643383279},
},
} {
assert.Equal(t, tc.expected, c.fromJsonTemplateFunc(tc.s))
t.Run(strconv.Itoa(i), func(t *testing.T) {
assert.Equal(t, tc.expected, c.fromJsonTemplateFunc(tc.s))
})
}
}

Expand Down
46 changes: 46 additions & 0 deletions internal/cmd/testdata/scripts/issue3325.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,51 @@
exec chezmoi execute-template '{{ "{\"key\":1}" | fromJson | toToml }}'
cmp stdout golden/stdout

# test that integer types are preserved in the .data section of JSONC config files
exec chezmoi execute-template '{{ .data | toToml }}'
cmp stdout golden/config.toml

# test that integer and floating point types are preserved from .chezmoidata.json files
exec chezmoi execute-template '{{ .json | toToml }}'
cmp stdout golden/json.toml

# test that integer and floating point types are preserved from .chezmoidata.jsonc files
exec chezmoi execute-template '{{ .jsonc | toToml }}'
cmp stdout golden/jsonc.toml

-- golden/config.toml --
dataFloat64 = 1.1
dataInt64 = 2
-- golden/json.toml --
jsonFloat64 = 3.3
jsonInt64 = 4
-- golden/jsonc.toml --
jsoncFloat64 = 5.5
jsoncInt64 = 6
-- golden/stdout --
key = 1
-- home/user/.config/chezmoi/chezmoi.jsonc --
{
// Comment
"data": {
"data": {
"dataFloat64": 1.1,
"dataInt64": 2,
}
}
}
-- home/user/.local/share/chezmoi/.chezmoidata.json --
{
"json": {
"jsonFloat64": 3.3,
"jsonInt64": 4
}
}
-- home/user/.local/share/chezmoi/.chezmoidata.jsonc --
{
// Comment
"jsonc": {
"jsoncFloat64": 5.5,
"jsoncInt64": 6,
}
}

0 comments on commit 694977b

Please sign in to comment.