diff --git a/pointer.go b/pointer.go index bd115fb..2369c18 100644 --- a/pointer.go +++ b/pointer.go @@ -135,7 +135,7 @@ func (p *Pointer) String() string { // pointer "/foo/bar" against {"foo": {"bar": 21}} returns 9, the index of // the opening quote of "bar". // - Array element: the offset points to the first byte of the value at that -// index. For example, pointer "/0/1" against [[1,2], [3,4]] returns 3, +// index. For example, pointer "/0/1" against [[1,2], [3,4]] returns 4, // the index of the digit 2. // // # Errors @@ -180,7 +180,35 @@ func (p *Pointer) Offset(document string) (int64, error) { return 0, fmt.Errorf("invalid token %#v: %w", tk, ErrPointer) } } - return offset, nil + return skipJSONSeparator(document, offset), nil +} + +// skipJSONSeparator advances offset past trailing JSON whitespace and at most +// one value separator (comma) in document, so the result points at the first +// byte of the next JSON token. +// +// The streaming decoder's InputOffset sits right after the most recently +// consumed token, which between values is the comma (or whitespace) — not +// the following token. Normalizing here keeps Offset's contract uniform: +// for both object keys and array elements, and regardless of position within +// the parent container, the returned offset always points at the first byte +// of the addressed token. +func skipJSONSeparator(document string, offset int64) int64 { + n := int64(len(document)) + for offset < n && isJSONWhitespace(document[offset]) { + offset++ + } + if offset < n && document[offset] == ',' { + offset++ + } + for offset < n && isJSONWhitespace(document[offset]) { + offset++ + } + return offset +} + +func isJSONWhitespace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' } // "Constructor", parses the given string JSON pointer. @@ -614,24 +642,27 @@ func offsetSingleObject(dec *json.Decoder, decodedToken string) (int64, error) { if err != nil { return 0, err } - switch tk := tk.(type) { - case json.Delim: - switch tk { - case '{': - if err = drainSingle(dec); err != nil { - return 0, err - } - case '[': + key, ok := tk.(string) + if !ok { + return 0, fmt.Errorf("invalid key token %#v: %w", tk, ErrPointer) + } + if key == decodedToken { + return offset, nil + } + + // Consume the associated value. Scalars are fully read by a single + // Token() call; composite values must be drained. + tk, err = dec.Token() + if err != nil { + return 0, err + } + if delim, isDelim := tk.(json.Delim); isDelim { + switch delim { + case '{', '[': if err = drainSingle(dec); err != nil { return 0, err } } - case string: - if tk == decodedToken { - return offset, nil - } - default: - return 0, fmt.Errorf("invalid token %#v: %w", tk, ErrPointer) } } diff --git a/pointer_test.go b/pointer_test.go index 1849b48..758a4ad 100644 --- a/pointer_test.go +++ b/pointer_test.go @@ -951,7 +951,7 @@ func TestOffset(t *testing.T) { name: "array index", ptr: "/0/1", input: `[[1,2], [3,4]]`, - offset: 3, + offset: 4, }, { name: "mix array index and object key", @@ -971,6 +971,66 @@ func TestOffset(t *testing.T) { input: `[[1,2], [3,4]]`, hasError: true, }, + { + name: "array element after nested object", + ptr: "/1", + input: `[{"x":1}, 42]`, + offset: 10, + }, + { + name: "array element after nested array", + ptr: "/1", + input: `[[1,2], 42]`, + offset: 8, + }, + { + name: "array element after mixed composites", + ptr: "/2", + input: `[{"x":1}, [3,4], 42]`, + offset: 17, + }, + { + name: "object key after scalar sibling", + ptr: "/b", + input: `{"a": 1, "b": 2}`, + offset: 9, + }, + { + name: "object key after composite sibling", + ptr: "/bar", + input: `{"foo": {}, "bar": 1}`, + offset: 12, + }, + { + name: "whitespace between value and comma", + ptr: "/b", + input: `{"a":1 ,"b":2}`, + offset: 8, + }, + { + name: "array index is not a number", + ptr: "/foo", + input: `[1,2,3]`, + hasError: true, + }, + { + name: "pointer traverses through a scalar", + ptr: "/foo/bar", + input: `{"foo": 42}`, + hasError: true, + }, + { + name: "malformed JSON document", + ptr: "/0", + input: `not json`, + hasError: true, + }, + { + name: "missing object key with nested composite siblings", + ptr: "/c", + input: `{"a":{}, "b":[]}`, + hasError: true, + }, } for _, tt := range cases { @@ -1109,4 +1169,85 @@ func TestInternalEdgeCases(t *testing.T) { require.ErrorContains(t, err, `can't set struct field`) }) }) + + t.Run("assignReflectValue is a no-op when src is an untyped nil", func(t *testing.T) { + target := "unchanged" + dst := reflect.ValueOf(&target).Elem() + + assignReflectValue(dst, nil) + + require.Equal(t, "unchanged", target) + }) +} + +func TestSetIntermediateErrors(t *testing.T) { + t.Parallel() + + type leaf struct { + V string `json:"v"` + } + type doc struct { + M map[string]leaf `json:"m"` + L []leaf `json:"l"` + S leaf `json:"s"` + N int `json:"n"` + P pointableImpl `json:"p"` + } + + newDoc := func() *doc { + return &doc{ + M: map[string]leaf{"present": {V: "x"}}, + L: []leaf{{V: "a"}, {V: "b"}}, + P: pointableImpl{a: "hello"}, + } + } + + cases := []struct { + name string + pointer string + substr string + }{ + { + name: "map missing key mid-path", + pointer: "/m/missing/v", + substr: `no key "missing"`, + }, + { + name: "slice non-numeric index mid-path", + pointer: "/l/abc/v", + substr: `parsing "abc"`, + }, + { + name: "slice out-of-bounds mid-path", + pointer: "/l/99/v", + substr: `out of bounds`, + }, + { + name: "struct unknown field mid-path", + pointer: "/s/bogus/v", + substr: `no field "bogus"`, + }, + { + name: "scalar traversal mid-path", + pointer: "/n/anything/v", + substr: `invalid token reference "anything"`, + }, + { + name: "JSONPointable returns error mid-path", + pointer: "/p/unknown/v", + substr: `no field "unknown"`, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + ptr, err := New(tt.pointer) + require.NoError(t, err) + + _, err = ptr.Set(newDoc(), "value") + require.Error(t, err) + require.ErrorIs(t, err, ErrPointer) + require.ErrorContains(t, err, tt.substr) + }) + } }