From d233cbaddbf44bc85df67797422fa65e1829c0ba Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 26 Jul 2026 00:37:32 +0200 Subject: [PATCH] EncodeD JSON output as UTF-8 bytes for the JSON encoder in the Playdate bindings target. --- .../PlaydateKit/JSON/Classes/Encoder.swift | 17 ++++++----- Tests/PlaydateKit/MockPlaydate.swift | 30 +++++++++++++++++++ Tests/PlaydateKit/WrapperTests.swift | 14 +++++++++ 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/Sources/PlaydateKit/JSON/Classes/Encoder.swift b/Sources/PlaydateKit/JSON/Classes/Encoder.swift index 505bcbb..e0e32b2 100644 --- a/Sources/PlaydateKit/JSON/Classes/Encoder.swift +++ b/Sources/PlaydateKit/JSON/Classes/Encoder.swift @@ -4,7 +4,7 @@ extension JSON { /// A streaming JSON encoder writing into a string. Wraps `json_encoder`. public final class Encoder { private final class Output { - var text = "" + var bytes: [UInt8] = [] } private var encoder = json_encoder() @@ -14,13 +14,12 @@ extension JSON { jsonAPI.pointee.initEncoder.unsafelyUnwrapped(&encoder, { userdata, string, length in guard let userdata, let string else { return } let output = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - let bytes = UnsafeRawBufferPointer(start: string, count: Int(length)) - output.text += String(decoding: bytes, as: UTF8.self) + output.bytes.append(contentsOf: UnsafeRawBufferPointer(start: string, count: Int(length))) }, Unmanaged.passUnretained(output).toOpaque(), pretty ? 1 : 0) } /// The JSON produced so far. - public var json: String { output.text } + public var json: String { String(decoding: output.bytes, as: UTF8.self) } /// Starts a JSON array. public func startArray() { @@ -44,9 +43,10 @@ extension JSON { /// Call before writing each table value. public func addTableMember(name: String) { - name.withPlaydateCString { cName in + name.withPlaydateUTF8 { bytes, count in withUnsafeMutablePointer(to: &encoder) { - $0.pointee.addTableMember.unsafelyUnwrapped($0, cName, Int32(name.utf8.count)) + $0.pointee.addTableMember.unsafelyUnwrapped( + $0, bytes.assumingMemoryBound(to: CChar.self), Int32(count)) } } } @@ -80,9 +80,10 @@ extension JSON { /// Writes a string value. public func writeString(_ value: String) { - value.withPlaydateCString { cString in + value.withPlaydateUTF8 { bytes, count in withUnsafeMutablePointer(to: &encoder) { - $0.pointee.writeString.unsafelyUnwrapped($0, cString, Int32(value.utf8.count)) + $0.pointee.writeString.unsafelyUnwrapped( + $0, bytes.assumingMemoryBound(to: CChar.self), Int32(count)) } } } diff --git a/Tests/PlaydateKit/MockPlaydate.swift b/Tests/PlaydateKit/MockPlaydate.swift index 4ad2d74..50b1685 100644 --- a/Tests/PlaydateKit/MockPlaydate.swift +++ b/Tests/PlaydateKit/MockPlaydate.swift @@ -348,5 +348,35 @@ enum Mock { data: .init(tableval: root)) return 1 } + + // A simplified encoder: each call emits its JSON fragment verbatim, + // without the real OS's separator and pretty-printing logic. + jsonAPI.pointee.initEncoder = { encoder, write, userdata, _ in + Mock.record("initEncoder") + guard let encoder else { return } + encoder.pointee.writeStringFunc = write + encoder.pointee.userdata = userdata + encoder.pointee.startTable = { Mock.emitJSON($0, "{") } + encoder.pointee.endTable = { Mock.emitJSON($0, "}") } + encoder.pointee.startArray = { Mock.emitJSON($0, "[") } + encoder.pointee.endArray = { Mock.emitJSON($0, "]") } + encoder.pointee.addTableMember = { encoder, name, length in + guard let name else { return } + let bytes = UnsafeRawBufferPointer(start: name, count: Int(length)) + Mock.emitJSON(encoder, "\"\(String(decoding: bytes, as: UTF8.self))\":") + } + encoder.pointee.addArrayMember = { Mock.emitJSON($0, ",") } + encoder.pointee.writeInt = { Mock.emitJSON($0, "\($1)") } + encoder.pointee.writeString = { encoder, string, length in + guard let string else { return } + let bytes = UnsafeRawBufferPointer(start: string, count: Int(length)) + Mock.emitJSON(encoder, "\"\(String(decoding: bytes, as: UTF8.self))\"") + } + } + } + + private static func emitJSON(_ encoder: UnsafeMutablePointer?, _ text: String) { + guard let encoder, let write = encoder.pointee.writeStringFunc else { return } + text.withCString { write(encoder.pointee.userdata, $0, Int32(text.utf8.count)) } } } diff --git a/Tests/PlaydateKit/WrapperTests.swift b/Tests/PlaydateKit/WrapperTests.swift index 2f72989..6c15e71 100644 --- a/Tests/PlaydateKit/WrapperTests.swift +++ b/Tests/PlaydateKit/WrapperTests.swift @@ -224,4 +224,18 @@ struct WrapperTests { return } } + + @Test func jsonEncoderAccumulatesOutput() { + let encoder = JSON.Encoder() + encoder.startTable() + encoder.addTableMember(name: "level") + encoder.writeInt(3) + encoder.addTableMember(name: "name") + encoder.writeString("Röck") + encoder.endTable() + + // The mock emits fragments verbatim, with no separators between + // members; "Röck" exercises multi-byte UTF-8 through the byte buffer. + #expect(encoder.json == "{\"level\":3\"name\":\"Röck\"}") + } }