Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 47 additions & 16 deletions pointer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
}

Expand Down
143 changes: 142 additions & 1 deletion pointer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
})
}
}
Loading