Skip to content

Commit 1dcce75

Browse files
committed
Add JSON parse and stringify builtins
1 parent cd52bce commit 1dcce75

5 files changed

Lines changed: 251 additions & 1 deletion

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ Goal: reduce host-side boilerplate for common scripting tasks.
281281

282282
### Core Utilities
283283

284-
- [ ] Add JSON parse/stringify built-ins.
284+
- [x] Add JSON parse/stringify built-ins.
285285
- [ ] Add regex matching/replacement helpers.
286286
- [ ] Add UUID/random identifier utilities with deterministic test hooks.
287287
- [ ] Add richer date/time parsing helpers for common layouts.

docs/builtins.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,27 @@ end
5656

5757
For time manipulation in VibeScript, use the `Time` object (`Time.now`, `Time.parse`, `Time.utc`, etc.). See `docs/time.md`.
5858

59+
## JSON
60+
61+
### `JSON.parse(string)`
62+
63+
Parses a JSON string into VibeScript values (`hash`, `array`, `string`, `int`,
64+
`float`, `bool`, `nil`):
65+
66+
```vibe
67+
payload = JSON.parse("{\"id\":\"p-1\",\"score\":10}")
68+
payload[:score] # 10
69+
```
70+
71+
### `JSON.stringify(value)`
72+
73+
Serializes supported values (`hash`/`object`, `array`, scalar primitives) into
74+
a JSON string:
75+
76+
```vibe
77+
raw = JSON.stringify({ id: "p-1", score: 10, tags: ["a", "b"] })
78+
```
79+
5980
## Module Loading
6081

6182
### `require(module_name, as: alias?)`

vibes/builtins.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package vibes
22

33
import (
4+
"encoding/json"
45
"fmt"
6+
"io"
7+
"reflect"
8+
"strings"
59
"time"
610
)
711

@@ -65,3 +69,165 @@ func builtinNow(exec *Execution, receiver Value, args []Value, kwargs map[string
6569
}
6670
return NewString(time.Now().UTC().Format(time.RFC3339)), nil
6771
}
72+
73+
type jsonStringifyState struct {
74+
seenArrays map[uintptr]struct{}
75+
seenHashes map[uintptr]struct{}
76+
}
77+
78+
func builtinJSONParse(exec *Execution, receiver Value, args []Value, kwargs map[string]Value, block Value) (Value, error) {
79+
if len(args) != 1 || args[0].Kind() != KindString {
80+
return NewNil(), fmt.Errorf("JSON.parse expects a single JSON string argument")
81+
}
82+
if len(kwargs) > 0 {
83+
return NewNil(), fmt.Errorf("JSON.parse does not accept keyword arguments")
84+
}
85+
if !block.IsNil() {
86+
return NewNil(), fmt.Errorf("JSON.parse does not accept blocks")
87+
}
88+
89+
decoder := json.NewDecoder(strings.NewReader(args[0].String()))
90+
decoder.UseNumber()
91+
92+
var decoded any
93+
if err := decoder.Decode(&decoded); err != nil {
94+
return NewNil(), fmt.Errorf("JSON.parse invalid JSON: %v", err)
95+
}
96+
if err := decoder.Decode(&struct{}{}); err != io.EOF {
97+
return NewNil(), fmt.Errorf("JSON.parse invalid JSON: trailing data")
98+
}
99+
100+
value, err := jsonValueToVibeValue(decoded)
101+
if err != nil {
102+
return NewNil(), err
103+
}
104+
return value, nil
105+
}
106+
107+
func builtinJSONStringify(exec *Execution, receiver Value, args []Value, kwargs map[string]Value, block Value) (Value, error) {
108+
if len(args) != 1 {
109+
return NewNil(), fmt.Errorf("JSON.stringify expects a single value argument")
110+
}
111+
if len(kwargs) > 0 {
112+
return NewNil(), fmt.Errorf("JSON.stringify does not accept keyword arguments")
113+
}
114+
if !block.IsNil() {
115+
return NewNil(), fmt.Errorf("JSON.stringify does not accept blocks")
116+
}
117+
118+
state := &jsonStringifyState{
119+
seenArrays: map[uintptr]struct{}{},
120+
seenHashes: map[uintptr]struct{}{},
121+
}
122+
encoded, err := vibeValueToJSONValue(args[0], state)
123+
if err != nil {
124+
return NewNil(), err
125+
}
126+
127+
payload, err := json.Marshal(encoded)
128+
if err != nil {
129+
return NewNil(), fmt.Errorf("JSON.stringify failed: %v", err)
130+
}
131+
return NewString(string(payload)), nil
132+
}
133+
134+
func jsonValueToVibeValue(val any) (Value, error) {
135+
switch v := val.(type) {
136+
case nil:
137+
return NewNil(), nil
138+
case bool:
139+
return NewBool(v), nil
140+
case string:
141+
return NewString(v), nil
142+
case json.Number:
143+
if i, err := v.Int64(); err == nil {
144+
return NewInt(i), nil
145+
}
146+
f, err := v.Float64()
147+
if err != nil {
148+
return NewNil(), fmt.Errorf("JSON.parse invalid number %q", v.String())
149+
}
150+
return NewFloat(f), nil
151+
case float64:
152+
return NewFloat(v), nil
153+
case []any:
154+
arr := make([]Value, len(v))
155+
for i, item := range v {
156+
converted, err := jsonValueToVibeValue(item)
157+
if err != nil {
158+
return NewNil(), err
159+
}
160+
arr[i] = converted
161+
}
162+
return NewArray(arr), nil
163+
case map[string]any:
164+
obj := make(map[string]Value, len(v))
165+
for key, item := range v {
166+
converted, err := jsonValueToVibeValue(item)
167+
if err != nil {
168+
return NewNil(), err
169+
}
170+
obj[key] = converted
171+
}
172+
return NewHash(obj), nil
173+
default:
174+
return NewNil(), fmt.Errorf("JSON.parse unsupported value type %T", val)
175+
}
176+
}
177+
178+
func vibeValueToJSONValue(val Value, state *jsonStringifyState) (any, error) {
179+
switch val.Kind() {
180+
case KindNil:
181+
return nil, nil
182+
case KindBool:
183+
return val.Bool(), nil
184+
case KindInt:
185+
return val.Int(), nil
186+
case KindFloat:
187+
return val.Float(), nil
188+
case KindString, KindSymbol:
189+
return val.String(), nil
190+
case KindArray:
191+
arr := val.Array()
192+
id := reflect.ValueOf(arr).Pointer()
193+
if id != 0 {
194+
if _, seen := state.seenArrays[id]; seen {
195+
return nil, fmt.Errorf("JSON.stringify does not support cyclic arrays")
196+
}
197+
state.seenArrays[id] = struct{}{}
198+
defer delete(state.seenArrays, id)
199+
}
200+
201+
out := make([]any, len(arr))
202+
for i, item := range arr {
203+
converted, err := vibeValueToJSONValue(item, state)
204+
if err != nil {
205+
return nil, err
206+
}
207+
out[i] = converted
208+
}
209+
return out, nil
210+
case KindHash, KindObject:
211+
hash := val.Hash()
212+
id := reflect.ValueOf(hash).Pointer()
213+
if id != 0 {
214+
if _, seen := state.seenHashes[id]; seen {
215+
return nil, fmt.Errorf("JSON.stringify does not support cyclic objects")
216+
}
217+
state.seenHashes[id] = struct{}{}
218+
defer delete(state.seenHashes, id)
219+
}
220+
221+
out := make(map[string]any, len(hash))
222+
for key, item := range hash {
223+
converted, err := vibeValueToJSONValue(item, state)
224+
if err != nil {
225+
return nil, err
226+
}
227+
out[key] = converted
228+
}
229+
return out, nil
230+
default:
231+
return nil, fmt.Errorf("JSON.stringify unsupported value type %s", val.Kind())
232+
}
233+
}

vibes/interpreter.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ func NewEngine(cfg Config) (*Engine, error) {
6868
engine.RegisterBuiltin("money_cents", builtinMoneyCents)
6969
engine.RegisterBuiltin("require", builtinRequire)
7070
engine.RegisterZeroArgBuiltin("now", builtinNow)
71+
engine.builtins["JSON"] = NewObject(map[string]Value{
72+
"parse": NewBuiltin("JSON.parse", builtinJSONParse),
73+
"stringify": NewBuiltin("JSON.stringify", builtinJSONStringify),
74+
})
7175
engine.builtins["Duration"] = NewObject(map[string]Value{
7276
"build": NewBuiltin("Duration.build", func(exec *Execution, receiver Value, args []Value, kwargs map[string]Value, block Value) (Value, error) {
7377
if len(args) == 1 && len(kwargs) == 0 {

vibes/runtime_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1650,6 +1650,65 @@ func TestTimeParseAndAliases(t *testing.T) {
16501650
}
16511651
}
16521652

1653+
func TestJSONBuiltins(t *testing.T) {
1654+
script := compileScript(t, `
1655+
def parse_payload()
1656+
JSON.parse("{\"name\":\"alex\",\"score\":10,\"tags\":[\"x\",true,null],\"ratio\":1.5}")
1657+
end
1658+
1659+
def stringify_payload()
1660+
payload = { name: "alex", score: 10, tags: ["x", true, nil], ratio: 1.5 }
1661+
JSON.stringify(payload)
1662+
end
1663+
1664+
def parse_invalid()
1665+
JSON.parse("{bad")
1666+
end
1667+
1668+
def stringify_unsupported()
1669+
JSON.stringify({ fn: helper })
1670+
end
1671+
1672+
def helper(value)
1673+
value
1674+
end
1675+
`)
1676+
1677+
parsed := callFunc(t, script, "parse_payload", nil)
1678+
if parsed.Kind() != KindHash {
1679+
t.Fatalf("expected parsed payload hash, got %v", parsed.Kind())
1680+
}
1681+
obj := parsed.Hash()
1682+
if !obj["name"].Equal(NewString("alex")) {
1683+
t.Fatalf("name mismatch: %v", obj["name"])
1684+
}
1685+
if !obj["score"].Equal(NewInt(10)) {
1686+
t.Fatalf("score mismatch: %v", obj["score"])
1687+
}
1688+
if obj["ratio"].Kind() != KindFloat || obj["ratio"].Float() != 1.5 {
1689+
t.Fatalf("ratio mismatch: %v", obj["ratio"])
1690+
}
1691+
compareArrays(t, obj["tags"], []Value{NewString("x"), NewBool(true), NewNil()})
1692+
1693+
stringified := callFunc(t, script, "stringify_payload", nil)
1694+
if stringified.Kind() != KindString {
1695+
t.Fatalf("expected JSON.stringify to return string, got %v", stringified.Kind())
1696+
}
1697+
if got := stringified.String(); got != `{"name":"alex","ratio":1.5,"score":10,"tags":["x",true,null]}` {
1698+
t.Fatalf("stringify mismatch: %q", got)
1699+
}
1700+
1701+
_, err := script.Call(context.Background(), "parse_invalid", nil, CallOptions{})
1702+
if err == nil || !strings.Contains(err.Error(), "JSON.parse invalid JSON") {
1703+
t.Fatalf("expected parse invalid JSON error, got %v", err)
1704+
}
1705+
1706+
_, err = script.Call(context.Background(), "stringify_unsupported", nil, CallOptions{})
1707+
if err == nil || !strings.Contains(err.Error(), "JSON.stringify unsupported value type function") {
1708+
t.Fatalf("expected stringify unsupported error, got %v", err)
1709+
}
1710+
}
1711+
16531712
func TestNumericHelpers(t *testing.T) {
16541713
script := compileScript(t, `
16551714
def int_helpers()

0 commit comments

Comments
 (0)