diff --git a/internal/chezmoi/format.go b/internal/chezmoi/format.go index 96ccdd1f8328..ba098344d168 100644 --- a/internal/chezmoi/format.go +++ b/internal/chezmoi/format.go @@ -1,8 +1,11 @@ package chezmoi import ( + "bytes" "encoding/json" + "errors" "fmt" + "io" "strings" "github.com/pelletier/go-toml/v2" @@ -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) @@ -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. @@ -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. @@ -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 +} diff --git a/internal/cmd/templatefuncs.go b/internal/cmd/templatefuncs.go index 110b7c05b547..d99049040c86 100644 --- a/internal/cmd/templatefuncs.go +++ b/internal/cmd/templatefuncs.go @@ -1,7 +1,6 @@ package cmd import ( - "bytes" "encoding/hex" "encoding/json" "errors" @@ -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" @@ -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 { @@ -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) diff --git a/internal/cmd/templatefuncs_test.go b/internal/cmd/templatefuncs_test.go index b435eeadb82c..fc457741b0e9 100644 --- a/internal/cmd/templatefuncs_test.go +++ b/internal/cmd/templatefuncs_test.go @@ -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)) + }) } } diff --git a/internal/cmd/testdata/scripts/issue3325.txtar b/internal/cmd/testdata/scripts/issue3325.txtar index b50fae6f18d7..7a7b246b9a92 100644 --- a/internal/cmd/testdata/scripts/issue3325.txtar +++ b/internal/cmd/testdata/scripts/issue3325.txtar @@ -2,5 +2,34 @@ exec chezmoi execute-template '{{ "{\"key\":1}" | fromJson | toToml }}' cmp stdout golden/stdout +# 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/json.toml -- +float64 = 2.2 +int64 = 1 +-- golden/jsonc.toml -- +float64 = 4.4 +int64 = 3 -- golden/stdout -- key = 1 +-- home/user/.local/share/chezmoi/.chezmoidata.json -- +{ + "json": { + "float64": 2.2, + "int64": 1 + } +} +-- home/user/.local/share/chezmoi/.chezmoidata.jsonc -- +{ + // Comment + "jsonc": { + "float64": 4.4, + "int64": 3, + } +}