From 4499cedcb87a350207dcd33d5381289aef93d1e4 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Mon, 28 Sep 2026 15:32:36 +0300 Subject: [PATCH] runtime: Json.write_file, and Json.encode through a kept buffer The encoder appends into RtJsonState's buffer (grown only past the biggest document yet): ints and Q16.16 fixeds written as digits in place, floats through string() and freed. Json.encode copies the answer out once; Json.write_file hands the buffer to Fs.write_text (.tmp + rename) and keeps nothing. json_saves.ludic: exact text, the file equals encode, parse round-trips, 1000 saves grow 0; clean under MallocScribble. json_quote (the + builder) is gone. Co-Authored-By: Claude Opus 5.5 --- changes/json-write-file.md | 7 + docs/language/json/json-write_file.md | 26 ++++ examples/lang/json_saves.ludic | 48 +++++++ runtime/native/namespaces.ludic | 1 + runtime/native/value.ludic | 178 ++++++++++++++++++++------ tools/ludic-cli/test.ludic | 1 + 6 files changed, 222 insertions(+), 39 deletions(-) create mode 100644 changes/json-write-file.md create mode 100644 docs/language/json/json-write_file.md create mode 100644 examples/lang/json_saves.ludic diff --git a/changes/json-write-file.md b/changes/json-write-file.md new file mode 100644 index 00000000..407e0b8d --- /dev/null +++ b/changes/json-write-file.md @@ -0,0 +1,7 @@ +bump: minor +type: feature +**`Json.write_file(value, path)` saves without keeping anything.** It writes the text +`Json.encode` would give straight to the file (through `path.tmp` and a rename), built in a buffer +the runtime keeps, so a game that saves often holds nothing more for it. `Json.encode` itself is +built the same way and makes only the string it returns: it used to join its text with `+`, keeping +every piece and every number's text. `examples/lang/json_saves.ludic`: 1000 saves, 0 bytes. diff --git a/docs/language/json/json-write_file.md b/docs/language/json/json-write_file.md new file mode 100644 index 00000000..a48bd013 --- /dev/null +++ b/docs/language/json/json-write_file.md @@ -0,0 +1,26 @@ +--- +id: json-write_file +name: Json.write_file +category: json +kind: namespace-method +tokens: Json.write_file +sig: Json.write_file(value, path) -> bool +tip: Write a value tree to a file as JSON, keeping nothing. +order: 5 +ns: Json +member: write_file +--- + +Writes the tree as the same text Json.encode would give, straight to path: to path.tmp first and then renamed over it, as Fs.write_text does, so a crash mid-write leaves the previous file whole. The text is built in a buffer the runtime keeps and grows only for a document bigger than any before, so a game can save as often as it likes and hold nothing more for it - where Json.encode hands back a string of its own every time. False when the file cannot be written. + +```ludic +program Save { + entry { + let o = Value.object() + Value.put(o, "day", Value.int(3)) + let path = Os.temp_dir() + "/save_doc.json" + print(Json.write_file(o, path)) + Fs.remove(path) + } +} +``` diff --git a/examples/lang/json_saves.ludic b/examples/lang/json_saves.ludic new file mode 100644 index 00000000..aac42c17 --- /dev/null +++ b/examples/lang/json_saves.ludic @@ -0,0 +1,48 @@ +# json_saves.ludic - Json.write_file writes a tree as JSON through a buffer the runtime keeps (the +# .tmp and the rename as Fs.write_text), so a game can save as often as it likes and keep nothing: +# the same text Json.encode gives, read back whole. Prints 1 2 3 and `saves grew 0` (the heap is +# read on the Mac; 0 elsewhere). +program JsonSaves { + function tree(n: int) -> Val { + let o = Value.object() + Value.put(o, "name", Value.str("camp \"north\"\nshore\\")) + Value.put(o, "day", Value.int(n)) + Value.put(o, "cold", Value.int(-2147483647)) + Value.put(o, "rep", Value.fixed(49152)) + Value.put(o, "pace", Value.float(float(7) / float(2))) + Value.put(o, "wet", Value.bool(0)) + Value.put(o, "none", Value.null()) + let l = Value.list() + for i in 0 .. 40 { Value.add(l, Value.int(i * 37 - 500)) } + Value.put(o, "pack", l) + let inner = Value.object() + Value.put(inner, "x", Value.fixed(-8192)) + Value.put(o, "at", inner) + return o + } + entry { + let small = Value.object() + Value.put(small, "a", Value.int(-7)) + Value.put(small, "b", Value.fixed(32768)) + Value.put(small, "c", Value.str("q\"\\\n")) + let sl = Value.list() + Value.add(sl, Value.bool(1)) + Value.add(sl, Value.null()) + Value.put(small, "d", sl) + if Json.encode(small) == "{\"a\":-7,\"b\":0.5,\"c\":\"q\\\"\\\\\\n\",\"d\":[true,null]}" { print(1) } + let path = Os.temp_dir() + "/json_saves_" + string(Os.pid()) + ".json" + let t = tree(3) + let wrote = Json.write_file(t, path) + let back = Fs.read_text(path) + let enc = Json.encode(t) + if wrote and back == enc { print(2) } + let again = Json.parse(back) + if Json.encode(again) == enc { print(3) } + for i in 0 .. 20 { Json.write_file(t, path) } + let before = Os.heap_bytes() + for i in 0 .. 1000 { Json.write_file(t, path) } + let grew = Os.heap_bytes() - before + Fs.remove(path) + print(`saves grew {grew}`) + } +} diff --git a/runtime/native/namespaces.ludic b/runtime/native/namespaces.ludic index c673302c..60d17447 100644 --- a/runtime/native/namespaces.ludic +++ b/runtime/native/namespaces.ludic @@ -488,6 +488,7 @@ namespace Value { } namespace Json { alias encode(value) = json_encode + alias write_file(value, path) = json_write_file # encode straight to a file, keeping nothing per call alias parse(text) = json_parse alias free(value) = json_free alias free_all(value) = json_free_all diff --git a/runtime/native/value.ludic b/runtime/native/value.ludic index d26b2fc5..86a2d2bf 100644 --- a/runtime/native/value.ludic +++ b/runtime/native/value.ludic @@ -304,22 +304,6 @@ function value_has(obj: Val, key: pointer) -> int { } # --- JSON encode ------------------------------------------------------------ -# escape a string's `"` `\` and newline, wrapping it in quotes. -function json_quote(s: pointer) -> string { - var out = "\"" - var i = 0 - let n = len(s) - while i < n { - let c = s[i] - if c == '"' { out += "\\\"" } - else { if c == '\\' { out += "\\\\" } - else { if c == '\n' { out += "\\n" } - else { out += s[i..i + 1] } } } - i += 1 - } - return out + "\"" -} - # a raw Q16.16 fixed -> its EXACT decimal string. The denominator is 2^16, so the # decimal always terminates (in <=16 fractional digits): emit digits until the # remainder hits zero. This is the exact inverse of jp_number's fixed decode, so @@ -341,31 +325,147 @@ function json_fixed_str(raw: int) -> string { return sign + string(ip) + "." + fs } -function json_encode(v: Val) -> string { - if v.tag == 0 { return "null" } - if v.tag == 1 { return string(v.num) } - if v.tag == 2 { return json_fixed_str(v.num) } - if v.tag == 3 { if v.num != 0 { return "true" }; return "false" } - if v.tag == 4 { return json_quote(v.txt) } - if v.tag == 7 { return string(float_from_bits(v.num)) } - if v.tag == 5 { - var out = "[" - var i = 0 - while i < value_n(v) { - if i > 0 { out += "," } - out += json_encode(v.kids[i]) - i += 1 +# The encoder writes into one buffer the runtime keeps, grown only when a document is bigger than +# any before: a save once built its text by `+`, keeping every piece and every number's text, and +# the answer was one more string a game could never free. `Json.encode` copies the answer out once; +# `Json.write_file` writes it through Fs.write_text (the .tmp and the rename) and keeps nothing. +export state RtJsonState { + jb: pointer = null, # the text so far, NUL-terminated when handed on + jcap: int = 0, + jn: int = 0 +} + +function jo_room(st: mut RtJsonState, more: int) -> void { + if st.jn + more + 1 <= st.jcap { return } + var cap = st.jcap + if cap < 4096 { cap = 4096 } + while cap < st.jn + more + 1 { cap = cap * 2 } + let nb = bytes(cap) + if st.jb != null { + let ob: bytes = st.jb + for i in 0 .. st.jn { nb[i] = ob[i] } + free(st.jb) + } + st.jb = nb + st.jcap = cap +} +function jo_put(st: mut RtJsonState, c: int) -> void { + jo_room(st, 1) + let b: bytes = st.jb + b[st.jn] = c + st.jn += 1 +} +function jo_str(st: mut RtJsonState, s: pointer) -> void { + let n = len(s) + jo_room(st, n) + let b: bytes = st.jb + for i in 0 .. n { b[st.jn + i] = s[i] } + st.jn += n +} +# an int's digits, written in place (no text made for it) +function jo_int(st: mut RtJsonState, v: int) -> void { + if v == 0 { + jo_put(st, '0') + return + } + var x: long = v + if x < 0 { + jo_put(st, '-') + x = -x + } + var div: long = 1 + while div * 10 <= x { div = div * 10 } + while div > 0 { + jo_put(st, 48 + int(x / div)) + x = x % div + div = div / 10 + } +} +# a raw Q16.16 fixed, exactly (json_fixed_str's digits, written in place) +function jo_fixed(st: mut RtJsonState, raw: int) -> void { + var a: long = raw + if a < 0 { + jo_put(st, '-') + a = -a + } + jo_int(st, int(a / 65536)) + jo_put(st, '.') + var frac = int(a % 65536) + if frac == 0 { + jo_put(st, '0') + return + } + while frac != 0 { + frac *= 10 + jo_put(st, 48 + frac / 65536) + frac = frac % 65536 + } +} +function jo_quote(st: mut RtJsonState, s: pointer) -> void { + jo_put(st, '"') + let n = len(s) + for i in 0 .. n { + let c = s[i] + if c == '"' or c == '\\' { + jo_put(st, '\\') + jo_put(st, c) + } else if c == '\n' { + jo_put(st, '\\') + jo_put(st, 'n') + } else { jo_put(st, c) } + } + jo_put(st, '"') +} +function jo_val(st: mut RtJsonState, v: Val) -> void { + if v.tag == 0 { jo_str(st, "null") } + else if v.tag == 1 { jo_int(st, v.num) } + else if v.tag == 2 { jo_fixed(st, v.num) } + else if v.tag == 3 { if v.num != 0 { jo_str(st, "true") } else { jo_str(st, "false") } } + else if v.tag == 4 { jo_quote(st, value_as_str(v)) } + else if v.tag == 7 { + let t = string(float_from_bits(v.num)) + jo_str(st, t) + free(t) + } else if v.tag == 5 { + jo_put(st, '[') + for i in 0 .. value_n(v) { + if i > 0 { jo_put(st, ',') } + jo_val(st, v.kids[i]) } - return out + "]" + jo_put(st, ']') + } else { + jo_put(st, '{') + for i in 0 .. value_n(v) { + if i > 0 { jo_put(st, ',') } + jo_quote(st, v.keys[i]) + jo_put(st, ':') + jo_val(st, v.kids[i]) + } + jo_put(st, '}') } - var out = "{" - var i = 0 - while i < value_n(v) { - if i > 0 { out += "," } - out = out + json_quote(v.keys[i]) + ":" + json_encode(v.kids[i]) - i += 1 - } - return out + "}" +} +# the whole document into the kept buffer, NUL-terminated +function jo_doc(st: mut RtJsonState, v: Val) -> void { + st.jn = 0 + jo_val(st, v) + jo_room(st, 0) + let b: bytes = st.jb + b[st.jn] = 0 +} + +function json_encode(rt_json_st: mut RtJsonState, v: Val) -> string { + jo_doc(rt_json_st, v) + let out = bytes(rt_json_st.jn + 1) + let b: bytes = rt_json_st.jb + for i in 0 .. rt_json_st.jn + 1 { out[i] = b[i] } + let s: string = out + return s +} +# v written to path as JSON, replacing it whole (the .tmp and the rename); nothing kept per call +function json_write_file(rt_json_st: mut RtJsonState, v: Val, path: string) -> bool { + jo_doc(rt_json_st, v) + let s: string = rt_json_st.jb + return Fs.write_text(path, s) } # --- JSON parse ------------------------------------------------------------- diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic index 316256a6..b430ac8c 100644 --- a/tools/ludic-cli/test.ludic +++ b/tools/ludic-cli/test.ludic @@ -1081,6 +1081,7 @@ function cmd_dev_test() -> int { reject_case("rejected/long_into_int", "4294967295 does not fit one; it is a long", "a long literal given to an int is refused, not wrapped") feat_case("lang/runtime_temps", "", "1 2 3 4 5 grew 0 files grew 0", "runtime_temps.ludic (Log, DateTime.format, Path, Mime, Text, Os and Fs keep nothing a call does not hand back)") feat_case("lang/string_temps", "", "1 2 3 4 5 6 7 grew 0", "string_temps.ludic (a concatenation's pieces, a template's holes and a compared side are freed once used; a kept one is not)") + feat_case("lang/json_saves", "", "1 2 3 saves grew 0", "json_saves.ludic (Json.write_file saves through a kept buffer: the text Json.encode gives, 1000 saves keep nothing)") feat_case("lang/nested_templates", "", "outer [inner 3 {x}] \"`}\" end", "nested_templates.ludic (a template literal inside another's hole)") feat_case("lang/aliases", "", "10 5 3", "aliases.ludic (L6: a namespace method declared as an alias of a function, labelled or by its parameters)") reject_case("rejected/alias_arity", "this call to Trail.length leaves out to, which has no default", "an alias's arguments are checked against its target")