From 84d79f0b9b8f311f9a1e8f2af327246662ecc2df Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Sat, 20 Jun 2026 13:54:23 +0200 Subject: [PATCH 1/3] add JSON mutation helpers set-key, delete-key, push, set-nth for shallow modification of objects and arrays. set-in and update-in for deep nested modification. get-in now supports integer array indices in paths. --- json.carp | 105 ++++++++++++++++++++- test/json.carp | 249 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 349 insertions(+), 5 deletions(-) diff --git a/json.carp b/json.carp index bb5916b..a8c1b72 100644 --- a/json.carp +++ b/json.carp @@ -679,15 +679,29 @@ array or the index is out of bounds. Returns a copy of the value.") (Maybe.Nothing) (Maybe.Nothing)) _ (Maybe.Nothing))) - (doc get-in "performs nested key lookup through objects. Returns a copy. + (private get-step) + (hidden get-step) + (defn get-step [j k] + (match-ref j + (JSON.Arr _) + (match (Int.from-string k) + (Maybe.Just i) (if (>= i 0) (JSON.nth j i) (Maybe.Nothing)) + (Maybe.Nothing) (Maybe.Nothing)) + _ (JSON.get j k))) + + (doc get-in "performs nested lookup through objects and arrays. String path +elements that parse as non-negative integers index into arrays; all other +elements are used as object keys. Returns a copy. ``` -(JSON.get-in &j &[@\"data\" @\"users\"]) +(JSON.get-in &j &[@\"data\" @\"users\" @\"0\" @\"name\"]) ```") (defn get-in [j keys] (Array.reduce &(fn [mj k] - (match mj (Maybe.Nothing) (Maybe.Nothing) (Maybe.Just j) (JSON.get &j k))) + (match mj + (Maybe.Nothing) (Maybe.Nothing) + (Maybe.Just j) (JSON.get-step &j k))) (Maybe.Just @j) keys)) @@ -706,7 +720,90 @@ automatically boxed.") (Array.reduce &(fn [m p] (Map.put m (Pair.a p) (Pair.b p))) (the (Map String (Box JSON)) {}) - &entries)))) + &entries))) + + (doc set-key "sets a key in a JSON object, replacing any existing value. +If the value is not an object, returns it unchanged.") + (defn set-key [j k v] + (match j + (JSON.Obj m) (let [bv (Box.init v)] (JSON.Obj (Map.put m k &bv))) + other (do (ignore v) other))) + + (doc delete-key "removes a key from a JSON object. +If the value is not an object or the key is absent, returns it unchanged.") + (defn delete-key [j k] + (match j (JSON.Obj m) (JSON.Obj (Map.remove m k)) other other)) + + (doc push "appends a value to a JSON array. +If the value is not an array, returns it unchanged.") + (defn push [j v] + (match j + (JSON.Arr a) (JSON.Arr (Array.push-back a (Box.init v))) + other (do (ignore v) other))) + + (doc set-nth "replaces the value at index `i` in a JSON array. +If the value is not an array or the index is out of bounds, returns it +unchanged.") + (defn set-nth [j i v] + (match j + (JSON.Arr a) + (if (and (>= i 0) (< i (Array.length &a))) + (JSON.Arr (Array.aset a i (Box.init v))) + (do (ignore v) (JSON.Arr a))) + other (do (ignore v) other))) + + (private set-in-at) + (hidden set-in-at) + (register set-in-at (Fn [JSON (Ref (Array String)) Int JSON] JSON)) + (defn set-in-at [j keys idx v] + (if (>= idx (Array.length keys)) + (do (ignore j) v) + (let [k (Array.unsafe-nth keys idx)] + (match j + (JSON.Arr a) + (match (Int.from-string k) + (Maybe.Just i) + (if (and (>= i 0) (< i (Array.length &a))) + (let [child (Box.unbox @(Array.unsafe-nth &a i)) + updated (JSON.set-in-at child keys (Int.inc idx) v)] + (JSON.Arr (Array.aset a i (Box.init updated)))) + (do (ignore v) (JSON.Arr a))) + (Maybe.Nothing) (do (ignore v) (JSON.Arr a))) + (JSON.Obj m) + (let [child (match (Map.get-maybe &m k) + (Maybe.Just bx) (Box.unbox bx) + (Maybe.Nothing) (JSON.Obj (the (Map String (Box JSON)) {}))) + updated (JSON.set-in-at child keys (Int.inc idx) v) + bv (Box.init updated)] + (JSON.Obj (Map.put m k &bv))) + other (do (ignore v) other))))) + + (doc set-in "sets a value at a nested path through objects and arrays. + +Path elements that parse as non-negative integers index into arrays; +all other elements are used as object keys. Missing intermediate object +keys are created automatically. + +``` +(JSON.set-in j &[@\"users\" @\"0\" @\"name\"] (JSON.Str @\"Alice\")) +```") + (defn set-in [j keys v] (JSON.set-in-at j keys 0 v)) + + (doc update-in "applies a function to the value at a nested path. + +Path elements that parse as non-negative integers index into arrays. +If the path does not resolve to a value, returns the input unchanged. + +``` +(JSON.update-in j &[@\"count\"] (fn [n] + (match (JSON.as-num &n) + (Maybe.Just x) (JSON.Num (+ x 1.0)) + (Maybe.Nothing) n))) +```") + (defn update-in [j keys f] + (match (JSON.get-in &j keys) + (Maybe.Just old) (JSON.set-in j keys (f old)) + (Maybe.Nothing) (do (ignore f) j)))) (doc to-json "converts a Carp value to its JSON representation.") (definterface to-json (Fn [a] JSON)) diff --git a/test/json.carp b/test/json.carp index 01d9c13..ed80722 100644 --- a/test/json.carp +++ b/test/json.carp @@ -920,4 +920,251 @@ break")) "str string with newline") (Result.Success j2) (= &(ser &j) &(ser &j2)) _ false) _ false)) - "pretty-str output parses back to equivalent structure")) + "pretty-str output parses back to equivalent structure") + + ; ========================================================================= + ; Mutation helpers -- set-key, delete-key, push, set-nth + ; ========================================================================= + + ; set-key + (assert-equal test + "{\"a\":1,\"b\":2}" + &(ser + &(JSON.set-key + (JSON.obj [(JSON.entry @"a" (JSON.Num 1.0))]) + "b" + (JSON.Num 2.0))) + "set-key adds a new key") + + (assert-equal test + "{\"a\":99}" + &(ser + &(JSON.set-key + (JSON.obj [(JSON.entry @"a" (JSON.Num 1.0))]) + "a" + (JSON.Num 99.0))) + "set-key replaces an existing key") + + (assert-equal test + "null" + &(ser &(JSON.set-key (JSON.Null) "x" (JSON.Num 1.0))) + "set-key on non-object returns unchanged") + + ; delete-key + (assert-equal test + "{}" + &(ser &(JSON.delete-key (JSON.obj [(JSON.entry @"a" (JSON.Num 1.0))]) "a")) + "delete-key removes existing key") + + (assert-true test + (let [j (JSON.obj [(JSON.entry @"a" (JSON.Num 1.0))])] + (match (JSON.get &(JSON.delete-key j "z") "a") (Maybe.Just _) true _ false)) + "delete-key on missing key returns unchanged") + + (assert-equal test + "[1]" + &(ser &(JSON.delete-key (JSON.Arr [(Box.init (JSON.Num 1.0))]) "x")) + "delete-key on non-object returns unchanged") + + ; push + (assert-equal test + "[1,2,3]" + &(ser + &(JSON.push + (JSON.Arr [(Box.init (JSON.Num 1.0)) (Box.init (JSON.Num 2.0))]) + (JSON.Num 3.0))) + "push appends to array") + + (assert-equal test + "[1]" + &(ser &(JSON.push (JSON.Arr (the (Array (Box JSON)) [])) (JSON.Num 1.0))) + "push to empty array") + + (assert-equal test + "null" + &(ser &(JSON.push (JSON.Null) (JSON.Num 1.0))) + "push on non-array returns unchanged") + + ; set-nth + (assert-equal test + "[1,99,3]" + &(ser + &(JSON.set-nth + (JSON.Arr + [(Box.init (JSON.Num 1.0)) + (Box.init (JSON.Num 2.0)) + (Box.init (JSON.Num 3.0))]) + 1 + (JSON.Num 99.0))) + "set-nth replaces element at index") + + (assert-equal test + "[99]" + &(ser + &(JSON.set-nth (JSON.Arr [(Box.init (JSON.Num 1.0))]) 0 (JSON.Num 99.0))) + "set-nth replaces first element") + + (assert-equal test + "[1,2]" + &(ser + &(JSON.set-nth + (JSON.Arr [(Box.init (JSON.Num 1.0)) (Box.init (JSON.Num 2.0))]) + 5 + (JSON.Num 99.0))) + "set-nth out of bounds returns unchanged") + + (assert-equal test + "null" + &(ser &(JSON.set-nth (JSON.Null) 0 (JSON.Num 1.0))) + "set-nth on non-array returns unchanged") + + ; ========================================================================= + ; get-in with array indices + ; ========================================================================= + + (assert-true test + (let [j (JSON.Arr [(Box.init (JSON.Str @"a")) (Box.init (JSON.Str @"b"))])] + (match (JSON.get-in &j &[@"1"]) + (Maybe.Just v) (= &(ser &v) "\"b\"") + _ false)) + "get-in indexes into array with numeric string") + + (assert-true test + (match (JSON.parse "{\"users\":[{\"name\":\"Alice\"},{\"name\":\"Bob\"}]}") + (Result.Success j) + (match (JSON.get-in &j &[@"users" @"1" @"name"]) + (Maybe.Just v) (= &(ser &v) "\"Bob\"") + _ false) + _ false) + "get-in traverses mixed objects and arrays") + + (assert-true test + (let [j (JSON.Arr [(Box.init (JSON.Num 1.0))])] + (Maybe.nothing? &(JSON.get-in &j &[@"5"]))) + "get-in returns Nothing for out-of-bounds array index") + + (assert-true test + (let [j (JSON.Arr [(Box.init (JSON.Num 1.0))])] + (Maybe.nothing? &(JSON.get-in &j &[@"abc"]))) + "get-in returns Nothing for non-numeric key on array") + + ; ========================================================================= + ; Deep mutation -- set-in, update-in + ; ========================================================================= + + ; set-in on objects + (assert-equal test + "{\"a\":99}" + &(ser + &(JSON.set-in + (JSON.obj [(JSON.entry @"a" (JSON.Num 1.0))]) + &[@"a"] + (JSON.Num 99.0))) + "set-in replaces value at single key") + + (assert-true test + (match (JSON.parse "{\"a\":{\"b\":1}}") + (Result.Success j) + (let [updated (JSON.set-in j &[@"a" @"b"] (JSON.Num 99.0))] + (match (JSON.get-in &updated &[@"a" @"b"]) + (Maybe.Just v) (= &(ser &v) "99") + _ false)) + _ false) + "set-in replaces nested value") + + ; set-in creates intermediate objects + (assert-true test + (let [j (JSON.obj []) + updated (JSON.set-in j &[@"a" @"b"] (JSON.Num 1.0))] + (match (JSON.get-in &updated &[@"a" @"b"]) + (Maybe.Just v) (= &(ser &v) "1") + _ false)) + "set-in creates intermediate objects for missing keys") + + ; set-in on arrays + (assert-equal test + "[1,99,3]" + &(ser + &(JSON.set-in + (JSON.Arr + [(Box.init (JSON.Num 1.0)) + (Box.init (JSON.Num 2.0)) + (Box.init (JSON.Num 3.0))]) + &[@"1"] + (JSON.Num 99.0))) + "set-in replaces array element by index") + + ; set-in on mixed structures + (assert-true test + (match (JSON.parse "{\"users\":[{\"name\":\"Alice\"},{\"name\":\"Bob\"}]}") + (Result.Success j) + (let [updated (JSON.set-in j + &[@"users" @"1" @"name"] + (JSON.Str @"Charlie"))] + (match (JSON.get-in &updated &[@"users" @"1" @"name"]) + (Maybe.Just v) (= &(ser &v) "\"Charlie\"") + _ false)) + _ false) + "set-in modifies nested value in mixed object/array structure") + + ; set-in out of bounds is a no-op + (assert-equal test + "[1,2]" + &(ser + &(JSON.set-in + (JSON.Arr [(Box.init (JSON.Num 1.0)) (Box.init (JSON.Num 2.0))]) + &[@"5"] + (JSON.Num 99.0))) + "set-in on out-of-bounds array index returns unchanged") + + ; set-in on non-container is a no-op + (assert-equal test + "42" + &(ser &(JSON.set-in (JSON.Num 42.0) &[@"x"] (JSON.Num 1.0))) + "set-in on non-container returns unchanged") + + ; update-in + (assert-true test + (let [j (JSON.obj [(JSON.entry @"x" (JSON.Num 10.0))]) + updated (JSON.update-in j + &[@"x"] + (fn [n] + (match (JSON.as-num &n) + (Maybe.Just x) (JSON.Num (+ x 1.0)) + (Maybe.Nothing) n)))] + (match (JSON.get &updated "x") (Maybe.Just v) (= &(ser &v) "11") _ false)) + "update-in applies function to value at path") + + (assert-true test + (match (JSON.parse "{\"a\":{\"b\":5}}") + (Result.Success j) + (let [updated (JSON.update-in j + &[@"a" @"b"] + (fn [n] + (match (JSON.as-num &n) + (Maybe.Just x) (JSON.Num (* x 2.0)) + (Maybe.Nothing) n)))] + (match (JSON.get-in &updated &[@"a" @"b"]) + (Maybe.Just v) (= &(ser &v) "10") + _ false)) + _ false) + "update-in applies function at nested path") + + (assert-true test + (let [j (JSON.obj [(JSON.entry @"x" (JSON.Num 1.0))]) + updated (JSON.update-in j &[@"missing"] (fn [n] (JSON.Num 99.0)))] + (match (JSON.get &updated "x") (Maybe.Just v) (= &(ser &v) "1") _ false)) + "update-in on missing path returns unchanged") + + (assert-true test + (let [j (JSON.Arr [(Box.init (JSON.Num 10.0)) (Box.init (JSON.Num 20.0))]) + updated (JSON.update-in j + &[@"0"] + (fn [n] + (match (JSON.as-num &n) + (Maybe.Just x) (JSON.Num (+ x 5.0)) + (Maybe.Nothing) n)))] + (match (JSON.get-in &updated &[@"0"]) + (Maybe.Just v) (= &(ser &v) "15") + _ false)) + "update-in works with array indices")) From 8a7ae4af67c5f0d16188c59302fa783149c1f35e Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Sun, 21 Jun 2026 10:41:04 +0200 Subject: [PATCH 2/3] remove markdown fences from doc strings to match house style --- json.carp | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/json.carp b/json.carp index a8c1b72..4a1cbf1 100644 --- a/json.carp +++ b/json.carp @@ -693,9 +693,7 @@ array or the index is out of bounds. Returns a copy of the value.") elements that parse as non-negative integers index into arrays; all other elements are used as object keys. Returns a copy. -``` -(JSON.get-in &j &[@\"data\" @\"users\" @\"0\" @\"name\"]) -```") + (JSON.get-in &j &[@\"data\" @\"users\" @\"0\" @\"name\"])") (defn get-in [j keys] (Array.reduce &(fn [mj k] @@ -711,10 +709,8 @@ automatically boxed.") (doc obj "builds a JSON object from an array of key-value pairs. -``` -(JSON.obj [(JSON.entry @\"a\" (JSON.Num 1.0)) - (JSON.entry @\"b\" (JSON.Bool true))]) -```") + (JSON.obj [(JSON.entry @\"a\" (JSON.Num 1.0)) + (JSON.entry @\"b\" (JSON.Bool true))])") (defn obj [entries] (JSON.Obj (Array.reduce @@ -784,9 +780,7 @@ Path elements that parse as non-negative integers index into arrays; all other elements are used as object keys. Missing intermediate object keys are created automatically. -``` -(JSON.set-in j &[@\"users\" @\"0\" @\"name\"] (JSON.Str @\"Alice\")) -```") + (JSON.set-in j &[@\"users\" @\"0\" @\"name\"] (JSON.Str @\"Alice\"))") (defn set-in [j keys v] (JSON.set-in-at j keys 0 v)) (doc update-in "applies a function to the value at a nested path. @@ -794,12 +788,10 @@ keys are created automatically. Path elements that parse as non-negative integers index into arrays. If the path does not resolve to a value, returns the input unchanged. -``` -(JSON.update-in j &[@\"count\"] (fn [n] - (match (JSON.as-num &n) - (Maybe.Just x) (JSON.Num (+ x 1.0)) - (Maybe.Nothing) n))) -```") + (JSON.update-in j &[@\"count\"] (fn [n] + (match (JSON.as-num &n) + (Maybe.Just x) (JSON.Num (+ x 1.0)) + (Maybe.Nothing) n)))") (defn update-in [j keys f] (match (JSON.get-in &j keys) (Maybe.Just old) (JSON.set-in j keys (f old)) From ad90cf9664d96e70943791aa8fa287b136996767 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Mon, 22 Jun 2026 09:16:49 +0200 Subject: [PATCH 3/3] remove section and fence comments from new tests --- test/json.carp | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/test/json.carp b/test/json.carp index ed80722..1571890 100644 --- a/test/json.carp +++ b/test/json.carp @@ -922,11 +922,6 @@ break")) "str string with newline") _ false)) "pretty-str output parses back to equivalent structure") - ; ========================================================================= - ; Mutation helpers -- set-key, delete-key, push, set-nth - ; ========================================================================= - - ; set-key (assert-equal test "{\"a\":1,\"b\":2}" &(ser @@ -950,7 +945,6 @@ break")) "str string with newline") &(ser &(JSON.set-key (JSON.Null) "x" (JSON.Num 1.0))) "set-key on non-object returns unchanged") - ; delete-key (assert-equal test "{}" &(ser &(JSON.delete-key (JSON.obj [(JSON.entry @"a" (JSON.Num 1.0))]) "a")) @@ -966,7 +960,6 @@ break")) "str string with newline") &(ser &(JSON.delete-key (JSON.Arr [(Box.init (JSON.Num 1.0))]) "x")) "delete-key on non-object returns unchanged") - ; push (assert-equal test "[1,2,3]" &(ser @@ -985,7 +978,6 @@ break")) "str string with newline") &(ser &(JSON.push (JSON.Null) (JSON.Num 1.0))) "push on non-array returns unchanged") - ; set-nth (assert-equal test "[1,99,3]" &(ser @@ -1018,10 +1010,6 @@ break")) "str string with newline") &(ser &(JSON.set-nth (JSON.Null) 0 (JSON.Num 1.0))) "set-nth on non-array returns unchanged") - ; ========================================================================= - ; get-in with array indices - ; ========================================================================= - (assert-true test (let [j (JSON.Arr [(Box.init (JSON.Str @"a")) (Box.init (JSON.Str @"b"))])] (match (JSON.get-in &j &[@"1"]) @@ -1048,11 +1036,6 @@ break")) "str string with newline") (Maybe.nothing? &(JSON.get-in &j &[@"abc"]))) "get-in returns Nothing for non-numeric key on array") - ; ========================================================================= - ; Deep mutation -- set-in, update-in - ; ========================================================================= - - ; set-in on objects (assert-equal test "{\"a\":99}" &(ser @@ -1072,7 +1055,6 @@ break")) "str string with newline") _ false) "set-in replaces nested value") - ; set-in creates intermediate objects (assert-true test (let [j (JSON.obj []) updated (JSON.set-in j &[@"a" @"b"] (JSON.Num 1.0))] @@ -1081,7 +1063,6 @@ break")) "str string with newline") _ false)) "set-in creates intermediate objects for missing keys") - ; set-in on arrays (assert-equal test "[1,99,3]" &(ser @@ -1094,7 +1075,6 @@ break")) "str string with newline") (JSON.Num 99.0))) "set-in replaces array element by index") - ; set-in on mixed structures (assert-true test (match (JSON.parse "{\"users\":[{\"name\":\"Alice\"},{\"name\":\"Bob\"}]}") (Result.Success j) @@ -1107,7 +1087,6 @@ break")) "str string with newline") _ false) "set-in modifies nested value in mixed object/array structure") - ; set-in out of bounds is a no-op (assert-equal test "[1,2]" &(ser @@ -1117,13 +1096,11 @@ break")) "str string with newline") (JSON.Num 99.0))) "set-in on out-of-bounds array index returns unchanged") - ; set-in on non-container is a no-op (assert-equal test "42" &(ser &(JSON.set-in (JSON.Num 42.0) &[@"x"] (JSON.Num 1.0))) "set-in on non-container returns unchanged") - ; update-in (assert-true test (let [j (JSON.obj [(JSON.entry @"x" (JSON.Num 10.0))]) updated (JSON.update-in j