From 9c8a3812c783b768dc13213d25dbc51bdc2faf53 Mon Sep 17 00:00:00 2001 From: Davide Date: Mon, 19 Jan 2026 09:39:07 +0100 Subject: [PATCH 1/7] Add encoding macros - Provide encoding + decoding expansions in a single SWON macro - Move shared helpers to separate file - Test encoding reversibility - Reoder swon C args - Fix memory leaks - Update README --- README.md | 2 +- Sources/SWON/SWON.swift | 8 +- .../{SWONMacro.swift => Decoding.swift} | 166 +++------ Sources/SWONMacros/Encoding.swift | 319 ++++++++++++++++++ Sources/SWONMacros/Helpers.swift | 71 ++++ Sources/SWONMacros/Macros.swift | 27 ++ Sources/SWON_C/include/swon.h | 42 ++- Sources/SWON_C/swon.c | 132 +++++--- Tests/SWONTests/SWONTests.swift | 60 +++- 9 files changed, 647 insertions(+), 180 deletions(-) rename Sources/SWONMacros/{SWONMacro.swift => Decoding.swift} (60%) create mode 100644 Sources/SWONMacros/Encoding.swift create mode 100644 Sources/SWONMacros/Helpers.swift diff --git a/README.md b/README.md index 90fbac1..bfd0e40 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,10 @@ SWON uses the amazingly written [cJSON][credits-cjson] parser (C) under the hood - Dictionary keys must be `String`. - Optionals in collections (e.g. `[Int?]`) are not supported. +- Nulls are not supported. ### Things to do -- Encode Swift to JSON (decode-only for now). - Enums with associated types. - Do not suppress errors inside an optional field (silently made nil). diff --git a/Sources/SWON/SWON.swift b/Sources/SWON/SWON.swift index 6e660bd..8a23ca1 100644 --- a/Sources/SWON/SWON.swift +++ b/Sources/SWON/SWON.swift @@ -4,7 +4,12 @@ @_exported import SWON_C -@attached(member, names: named(init(fromSWON:)), named(init(fromJSON:))) +@attached(member, names: + named(init(fromSWON:)), + named(init(fromJSON:)), + named(toSWON), + named(toJSON) +) public macro SWON() = #externalMacro( module: "SWONMacros", type: "SWONMacro" @@ -13,7 +18,6 @@ public macro SWON() = #externalMacro( public enum SWONError: Error { case required(String) case invalid(String) - case message(String) } extension swon_result { diff --git a/Sources/SWONMacros/SWONMacro.swift b/Sources/SWONMacros/Decoding.swift similarity index 60% rename from Sources/SWONMacros/SWONMacro.swift rename to Sources/SWONMacros/Decoding.swift index b4b011c..080aed4 100644 --- a/Sources/SWONMacros/SWONMacro.swift +++ b/Sources/SWONMacros/Decoding.swift @@ -6,8 +6,8 @@ import SwiftDiagnostics import SwiftSyntax import SwiftSyntaxMacros -public struct SWONMacro: MemberMacro { - public static func expansion( +struct SWONDecodeMacro: MemberMacro { + static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, conformingTo protocols: [TypeSyntax], @@ -23,7 +23,7 @@ public struct SWONMacro: MemberMacro { $0.decl.as(VariableDeclSyntax.self) } .filter { variable in - variable.bindings.first?.initializer == nil // ignore default-init vars + variable.bindings.first?.initializer == nil // Ignore default-init vars } var assignments: [String] = [] @@ -39,14 +39,14 @@ public struct SWONMacro: MemberMacro { // context.diagnose( // Diagnostic( // node: Syntax(node), -// message: SWONMessage(message: "Raw type of enum \(enumDecl.name.text) is \(rawType)") +// message: SWONMessage(message: "Raw type of enum \(enumType) is \(rawType)") // ) // ) switch rawType { case "String": assignments.append(""" var str: UnsafePointer! - try swon_get_string(root, &str).check("\(enumType)") + try swon_get_string(&str, root).check("\(enumType)") let rawValue = String(cString: str) guard let value = \(enumType)(rawValue: rawValue) else { throw SWONError.invalid("\(enumType).\\(rawValue)") @@ -56,7 +56,7 @@ public struct SWONMacro: MemberMacro { case "Int": assignments.append(""" var num: Int32 = 0 - try swon_get_integer(root, &num).check("\(enumType)") + try swon_get_integer(&num, root).check("\(enumType)") guard let value = \(enumType)(rawValue: Int(num)) else { throw SWONError.invalid("\(enumType).\\(num)") } @@ -83,7 +83,7 @@ public struct SWONMacro: MemberMacro { assignments.append(""" \(field) = try\(type.isOptional ? "?" : "") { var item = swon_t() - let itemResult = swon_get_object(root, "\(field)", &item) + let itemResult = swon_get_object(&item, root, "\(field)") try itemResult.check("\(field)") \(mapItem(to: type.decodedType(context: context), fieldName: field, varName: "item", nesting: 0, isOptional: type.isOptional)) }() @@ -99,13 +99,11 @@ public struct SWONMacro: MemberMacro { DeclSyntax(stringLiteral: """ init(fromJSON json: String) throws { var root = swon_t() - guard swon_create(json, &root) == SWONResultValid else { + defer { swon_free(&root) } + guard swon_parse(&root, json) == SWONResultValid else { let message = String(cString: swon_error_ptr()) throw SWONError.invalid("At \\(message)") } - defer { - swon_free(root) - } try self.init(fromSWON: root) } """) @@ -113,9 +111,8 @@ public struct SWONMacro: MemberMacro { } } -private extension SWONMacro { +private extension SWONDecodeMacro { static func mapItem(to type: DecodedType, fieldName: String, varName: String, nesting: Int, isOptional: Bool) -> String { - let typeExpr: String switch type { case .scalar(let typeName): let checkExpr = """ @@ -128,49 +125,47 @@ private extension SWONMacro { throw SWONError.invalid("\(fieldName)") } """ - typeExpr = { - switch typeName { - case "String": - return """ - var str: UnsafePointer! - let result = swon_get_string(\(varName), &str) - \(checkExpr) - return String(cString: str) - """ - case "Int": - return """ - var num: Int32 = 0 - let result = swon_get_integer(\(varName), &num) - \(checkExpr) - return Int(num) - """ - case "Double": - return """ - var num: Double = 0 - let result = swon_get_number(\(varName), &num) - \(checkExpr) - return num - """ - case "Bool": - return """ - var bool = false - let result = swon_get_bool(\(varName), &bool) - \(checkExpr) - return bool - """ - default: - return "return try \(typeName)(fromSWON: \(varName))" - } - }() + switch typeName { + case "String": + return """ + var str: UnsafePointer! + let result = swon_get_string(&str, \(varName)) + \(checkExpr) + return String(cString: str) + """ + case "Int": + return """ + var num: Int32 = 0 + let result = swon_get_integer(&num, \(varName)) + \(checkExpr) + return Int(num) + """ + case "Double": + return """ + var num: Double = 0 + let result = swon_get_number(&num, \(varName)) + \(checkExpr) + return num + """ + case "Bool": + return """ + var bool = false + let result = swon_get_bool(&bool, \(varName)) + \(checkExpr) + return bool + """ + default: + return "return try \(typeName)(fromSWON: \(varName))" + } case .optional(let wrappedType): - typeExpr = mapItem(to: wrappedType, fieldName: fieldName, varName: varName, nesting: nesting, isOptional: true) + return mapItem(to: wrappedType, fieldName: fieldName, varName: varName, nesting: nesting, isOptional: true) case .array(let elementType): - typeExpr = """ + return """ var list: [\(elementType.description)] = [] let size = swon_get_array_size(\(varName)) for i in 0.. DecodedType { - switch `as`(TypeSyntaxEnum.self) { - case .identifierType(let id): - return .scalar(id.name.text) - case .memberType(let m): - return .scalar(m.description) - case .arrayType(let arr): - return .array(arr.element.decodedType(context: context)) - case .dictionaryType(let dict): - return .dictionary( - key: dict.key.decodedType(context: context), - value: dict.value.decodedType(context: context) - ) - case .optionalType(let opt): - return .optional(opt.wrappedType.decodedType(context: context)) - default: - return .scalar(description) - } - } - - var isOptional: Bool { - guard case .optionalType = `as`(TypeSyntaxEnum.self) else { - return false - } - return true - } -} - -struct SWONMessage: DiagnosticMessage { - let severity: DiagnosticSeverity = .note - let message: String - var diagnosticID: MessageID { - MessageID(domain: "SWONMessage", id: "debug") } } diff --git a/Sources/SWONMacros/Encoding.swift b/Sources/SWONMacros/Encoding.swift new file mode 100644 index 0000000..619670c --- /dev/null +++ b/Sources/SWONMacros/Encoding.swift @@ -0,0 +1,319 @@ +// SPDX-FileCopyrightText: 2026 Davide De Rosa +// +// SPDX-License-Identifier: MIT + +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +struct SWONEncodeMacro: MemberMacro { + static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard declaration.is(StructDeclSyntax.self) || declaration.is(EnumDeclSyntax.self) else { + fatalError("@SWON can only be applied to a struct or enum") + } + let properties = declaration + .memberBlock + .members + .compactMap { + $0.decl.as(VariableDeclSyntax.self) + } + .filter { variable in + variable.bindings.first?.initializer == nil // Ignore default-init vars + } + + var assignments: [String] = [] + + // Parse enum from raw value + if let enumDecl = declaration.as(EnumDeclSyntax.self) { + guard let inheritance = enumDecl.inheritanceClause, + let inheritedType = inheritance.inheritedTypes.first else { + fatalError("Enumeration must inherit a raw type") + } + let enumType = enumDecl.name.text + let rawType = inheritedType.type.description + context.diagnose( + Diagnostic( + node: Syntax(node), + message: SWONMessage(message: "Raw type of enum \(enumType) is \(rawType)") + ) + ) + switch rawType { + case "String": + assignments.append(""" + var item = swon_t() + guard rawValue.withCString({ swon_create_string(&item, $0) }) else { + throw SWONError.invalid("\(enumType)(String)") + } + return item + """) + case "Int": + assignments.append(""" + var item = swon_t() + guard swon_create_number(&item, Double(rawValue)) else { + throw SWONError.invalid("\(enumType)(Int)") + } + return item + """) + default: + fatalError("Unsupported raw type '\(rawType)'") + } + } else { + // Parse struct fields + for prop in properties { + guard let field = prop.bindings.first?.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else { + continue + } + guard let type = prop.bindings.first?.typeAnnotation?.type else { + continue + } + context.diagnose( + Diagnostic( + node: Syntax(node), + message: SWONMessage(message: "Assigning \(field) of type \(type)") + ) + ) + let stmts = mapItem( + to: type.decodedType(context: context), + varName: field, + parentName: "root", + nesting: 0, + isOptional: type.isOptional + ) + assignments.append(contentsOf: stmts) + } + assignments.append("return root") + } + context.diagnose( + Diagnostic( + node: Syntax(node), + message: SWONMessage(message: "Assignments: \(assignments)") + ) + ) + return [ + DeclSyntax(stringLiteral: """ + func toSWON() throws -> swon_t { + var root = swon_t() + guard swon_create_object(&root) else { + throw SWONError.invalid("Unable to create root") + } + do { + \(assignments.joined(separator: "\n")) + } catch { + swon_free(&root) + throw error + } + } + """), + DeclSyntax(stringLiteral: """ + func toJSON() throws -> String { + var item = try toSWON() + defer { swon_free(&item) } + guard let cjson = swon_encode(item) else { + let message = String(cString: swon_error_ptr()) + throw SWONError.invalid("At \\(message)") + } + let json = String(cString: cjson) + swon_free_string(cjson) + return json + } + """) + ] + } +} + +private extension SWONEncodeMacro { + static func mapItem( + to type: DecodedType, + varName: String, + parentName: String, + nesting: Int, + isOptional: Bool, + isCollection: Bool = false, + isPair: Bool = false + ) -> [String] { + var stmts: [String] = [] + if isOptional { + stmts.append("if let \(varName) {") + } + switch type { + case .scalar: + stmts.append( + contentsOf: type.statements(forName: "\(varName)JSON", element: varName, pair: false) + ) + stmts.append(""" + guard swon_object_add_item(&\(parentName), "\(varName)", \(varName)JSON) else { + swon_free(&\(varName)JSON) + throw SWONError.invalid("Unable to add scalar to object in \(parentName) (\(varName)JSON)") + } + """) + case .optional(let wrappedType): + return mapItem( + to: wrappedType, + varName: varName, + parentName: parentName, + nesting: nesting, + isOptional: true + ) + case .array(let elementType): + let elementName = "element\(nesting)" + let itemName = "element\(nesting)JSON" + let itemExpr: [String] = { + switch elementType { + case .optional: + fatalError("Collection of optionals is not supported") + case .array: + return mapItem( + to: elementType, + varName: elementName, + parentName: parentName, + nesting: nesting + 1, + isOptional: false, + isCollection: true + ) + case .dictionary: + return mapItem( + to: elementType, + varName: elementName, + parentName: parentName, + nesting: nesting + 1, + isOptional: false, + isCollection: true + ) + case .scalar: + return elementType.statements( + forName: itemName, + element: elementName, + pair: false + ) + } + }() + stmts.append(""" + var \(varName)JSON = swon_t() + guard swon_create_array(&\(varName)JSON) else { + throw SWONError.invalid("Unable to create array in \(parentName) (\(varName)JSON)") + } + for element\(nesting) in \(varName)\(isPair ? ".value" : "") { + do { + \(itemExpr.joined(separator: "\n")) + guard swon_array_add_item(&\(varName)JSON, \(itemName)) else { + swon_free(&\(itemName)) + throw SWONError.invalid("Unable to add item to \(varName)JSON") + } + } catch { + swon_free(&\(varName)JSON) + throw error + } + } + """) + if !isCollection { + stmts.append(""" + guard swon_object_add_item(&\(parentName), "\(varName)", \(varName)JSON) else { + swon_free(&\(varName)JSON) + throw SWONError.invalid("Unable to set array in \(parentName)[\(varName)] (\(varName)JSON)") + } + """) + } + case .dictionary(let key, let valueType): + guard case .scalar(let keyTypeName) = key, keyTypeName == "String" else { + fatalError("Expected string keys") + } + let elementName = "element\(nesting)" + let itemName = "element\(nesting)JSON" + let itemExpr: [String] = { + switch valueType { + case .optional: + fatalError("Collection of optionals is not supported") + case .array: + return mapItem( + to: valueType, + varName: elementName, + parentName: parentName, + nesting: nesting + 1, + isOptional: false, + isCollection: true, + isPair: true + ) + case .dictionary: + return mapItem( + to: valueType, + varName: elementName, + parentName: parentName, + nesting: nesting + 1, + isOptional: false, + isCollection: true, + isPair: true + ) + case .scalar: + return valueType.statements( + forName: itemName, + element: elementName, + pair: true + ) + } + }() + stmts.append(""" + var \(varName)JSON = swon_t() + guard swon_create_object(&\(varName)JSON) else { + throw SWONError.invalid("Unable to create object in \(parentName)") + } + for element\(nesting) in \(varName)\(isPair ? ".value" : "") { + \(itemExpr.joined(separator: "\n")) + do { + guard swon_object_add_item(&\(varName)JSON, element\(nesting).key, \(itemName)) else { + swon_free(&\(itemName)) + throw SWONError.invalid("Unable to set item in \(varName)JSON[\\(element\(nesting).key)]") + } + } catch { + swon_free(&\(varName)JSON) + throw error + } + } + """) + if !isCollection { + stmts.append(""" + guard swon_object_add_item(&\(parentName), \"\(varName)\", \(varName)JSON) else { + swon_free(&\(varName)JSON) + throw SWONError.invalid("Unable to set dictionary in \(parentName)[\(varName)] (\(varName)JSON)") + } + """) + } + } + if isOptional { + stmts.append("}") + } + return stmts + } +} + +private extension DecodedType { + func statements(forName name: String, element: String, pair: Bool) -> [String] { + var stmts: [String] = [] + let suffix = pair ? ".value" : "" + var isScalar = true + stmts.append("var \(name) = swon_t()") + switch description { + case "Bool": + stmts.append("guard swon_create_bool(&\(name), \(element)\(suffix)) else {") + case "Int": + stmts.append("guard swon_create_number(&\(name), Double(\(element)\(suffix))) else {") + case "Double": + stmts.append("guard swon_create_number(&\(name), \(element)\(suffix)) else {") + case "String": + stmts.append("guard swon_create_string(&\(name), \(element)\(suffix)) else {") + default: + isScalar = false + stmts.append("\(name) = try \(element)\(suffix).toSWON()") + } + if isScalar { + stmts.append("throw SWONError.invalid(\"Unable to create item (\(name))\")") + stmts.append("}") + } + return stmts + } +} diff --git a/Sources/SWONMacros/Helpers.swift b/Sources/SWONMacros/Helpers.swift new file mode 100644 index 0000000..056567e --- /dev/null +++ b/Sources/SWONMacros/Helpers.swift @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2026 Davide De Rosa +// +// SPDX-License-Identifier: MIT + +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +indirect enum DecodedType { + case scalar(_ name: String) + case array(_ element: DecodedType) + case dictionary(key: DecodedType, value: DecodedType) + case optional(DecodedType) +} + +extension DecodedType { + var description: String { + switch self { + case .scalar(let name): + return name + case .array(let element): + return "[\(element.description)]" + case .dictionary(let key, let value): + return "[\(key.description): \(value.description)]" + case .optional(let wrapped): + switch wrapped { + case .scalar(let name): + return "\(name)?" + default: + return "\(wrapped.description)?" + } + } + } +} + +extension TypeSyntax { + func decodedType(context: some MacroExpansionContext) -> DecodedType { + switch `as`(TypeSyntaxEnum.self) { + case .identifierType(let id): + return .scalar(id.name.text) + case .memberType(let m): + return .scalar(m.description) + case .arrayType(let arr): + return .array(arr.element.decodedType(context: context)) + case .dictionaryType(let dict): + return .dictionary( + key: dict.key.decodedType(context: context), + value: dict.value.decodedType(context: context) + ) + case .optionalType(let opt): + return .optional(opt.wrappedType.decodedType(context: context)) + default: + return .scalar(description) + } + } + + var isOptional: Bool { + guard case .optionalType = `as`(TypeSyntaxEnum.self) else { + return false + } + return true + } +} + +struct SWONMessage: DiagnosticMessage { + let severity: DiagnosticSeverity = .note + let message: String + var diagnosticID: MessageID { + MessageID(domain: "SWONMessage", id: "debug") + } +} diff --git a/Sources/SWONMacros/Macros.swift b/Sources/SWONMacros/Macros.swift index b2b7228..1d81214 100644 --- a/Sources/SWONMacros/Macros.swift +++ b/Sources/SWONMacros/Macros.swift @@ -3,11 +3,38 @@ // SPDX-License-Identifier: MIT import SwiftCompilerPlugin +import SwiftSyntax import SwiftSyntaxMacros @main struct SWONMacrosPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ SWONMacro.self, + SWONEncodeMacro.self, + SWONDecodeMacro.self ] } + +public struct SWONMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + var result: [DeclSyntax] = [] + result += try SWONDecodeMacro.expansion( + of: node, + providingMembersOf: declaration, + conformingTo: protocols, + in: context + ) + result += try SWONEncodeMacro.expansion( + of: node, + providingMembersOf: declaration, + conformingTo: protocols, + in: context + ) + return result + } +} diff --git a/Sources/SWON_C/include/swon.h b/Sources/SWON_C/include/swon.h index dd9ef2b..1c7fd91 100644 --- a/Sources/SWON_C/include/swon.h +++ b/Sources/SWON_C/include/swon.h @@ -8,6 +8,7 @@ #include #include +#include "cJSON.h" typedef struct { void *impl; @@ -19,22 +20,35 @@ typedef enum { SWONResultNull } swon_result; -swon_result swon_create(const char *text, swon_t *root); -void swon_free(swon_t root); +swon_result swon_parse(swon_t *dst, const char *text); +char *swon_encode(swon_t src); +void swon_free_string(char *string); +void swon_free(swon_t *dst); -swon_result swon_get_object(swon_t json, const char *field, swon_t *object); -swon_result swon_get_number(swon_t json, double *value); -swon_result swon_get_integer(swon_t json, int *value); -swon_result swon_get_bool(swon_t json, bool *value); -swon_result swon_get_string(swon_t json, const char **value); +swon_result swon_get_object(swon_t *dst, swon_t src, const char *field); +swon_result swon_get_number(double *dst, swon_t src); +swon_result swon_get_integer(int *dst, swon_t src); +swon_result swon_get_bool(bool *dst, swon_t src); +swon_result swon_get_string(const char **dst, swon_t src); -swon_result swon_get_array(swon_t json, swon_t *array); -size_t swon_get_array_size(swon_t json); -swon_result swon_get_array_item(swon_t json, int index, swon_t *object); +swon_result swon_get_array(swon_t *dst, swon_t src); +size_t swon_get_array_size(swon_t src); +swon_result swon_get_array_item(swon_t *dst, swon_t src, int index); -swon_t swon_get_map_first(swon_t json); -bool swon_get_map_exists(swon_t json); -const char *swon_get_map_key(swon_t json); -swon_t swon_get_map_next(swon_t json); +swon_t swon_get_map_first(swon_t src); +bool swon_get_map_exists(swon_t src); +const char *swon_get_map_key(swon_t src); +swon_t swon_get_map_next(swon_t src); + +bool swon_create_array(swon_t *dst); +bool swon_array_add_item(swon_t *dst, swon_t item); + +bool swon_create_object(swon_t *dst); +bool swon_object_add_item(swon_t *dst, const char *field, swon_t item); + +bool swon_create_number(swon_t *dst, double value); +bool swon_create_integer(swon_t *dst, int value); +bool swon_create_bool(swon_t *dst, bool value); +bool swon_create_string(swon_t *dst, const char *value); const char *swon_error_ptr(); diff --git a/Sources/SWON_C/swon.c b/Sources/SWON_C/swon.c index 32f3646..4435c39 100644 --- a/Sources/SWON_C/swon.c +++ b/Sources/SWON_C/swon.c @@ -17,91 +17,139 @@ #define SWON_RETURN_RESULT_IF_INVALID(r) if (r != SWONResultValid) return r; #define SWON_RETURN_INVALID_IF(expr) if (expr) return SWONResultInvalid; -swon_result swon_create(const char *text, swon_t *root) { +swon_result swon_parse(swon_t *dst, const char *text) { cJSON *ret = cJSON_Parse(text); SWON_RETURN_IF_NULL(ret) - root->impl = ret; + dst->impl = ret; return SWONResultValid; } -void swon_free(swon_t root) { - free(root.impl); +char *swon_encode(swon_t src) { + return cJSON_PrintUnformatted(src.impl); } -swon_result swon_get_object(swon_t json, const char *field, swon_t *object) { - cJSON *item = cJSON_GetObjectItemCaseSensitive(json.impl, field); +void swon_free_string(char *string) { + if (!string) return; + cJSON_free(string); +} + +void swon_free(swon_t *dst) { + if (!dst->impl) return; + cJSON_Delete(dst->impl); +} + +swon_result swon_get_object(swon_t *dst, swon_t src, const char *field) { + cJSON *item = cJSON_GetObjectItemCaseSensitive(src.impl, field); SWON_RETURN_IF_NULL(item) - object->impl = item; + dst->impl = item; return SWONResultValid; } -swon_result swon_get_number(swon_t json, double *value) { - SWON_RETURN_IF_NULL(json.impl) - SWON_RETURN_INVALID_IF(!cJSON_IsNumber(json.impl)) - *value = cJSON_GetNumberValue(json.impl); +swon_result swon_get_number(double *dst, swon_t src) { + SWON_RETURN_IF_NULL(src.impl) + SWON_RETURN_INVALID_IF(!cJSON_IsNumber(src.impl)) + *dst = cJSON_GetNumberValue(src.impl); return SWONResultValid; } -swon_result swon_get_integer(swon_t json, int *value) { +swon_result swon_get_integer(int *dst, swon_t src) { double double_value; - const swon_result result = swon_get_number(json, &double_value); + const swon_result result = swon_get_number(&double_value, src); if (result != SWONResultValid) return result; - *value = (int)double_value; + *dst = (int)double_value; return SWONResultValid; } -swon_result swon_get_bool(swon_t json, bool *value) { - SWON_RETURN_IF_NULL(json.impl) - SWON_RETURN_INVALID_IF(!cJSON_IsBool(json.impl)) - *value = cJSON_IsTrue(json.impl); +swon_result swon_get_bool(bool *dst, swon_t src) { + SWON_RETURN_IF_NULL(src.impl) + SWON_RETURN_INVALID_IF(!cJSON_IsBool(src.impl)) + *dst = cJSON_IsTrue(src.impl); return SWONResultValid; } -swon_result swon_get_string(swon_t json, const char **value) { - SWON_RETURN_IF_NULL(json.impl) - SWON_RETURN_INVALID_IF(!cJSON_IsString(json.impl)) - *value = cJSON_GetStringValue(json.impl); +swon_result swon_get_string(const char **dst, swon_t src) { + SWON_RETURN_IF_NULL(src.impl) + SWON_RETURN_INVALID_IF(!cJSON_IsString(src.impl)) + *dst = cJSON_GetStringValue(src.impl); return SWONResultValid; } -swon_result swon_get_array(swon_t json, swon_t *array) { - SWON_RETURN_IF_NULL(json.impl) - SWON_RETURN_INVALID_IF(!cJSON_IsArray(json.impl)) - array->impl = json.impl; +swon_result swon_get_array(swon_t *dst, swon_t src) { + SWON_RETURN_IF_NULL(src.impl) + SWON_RETURN_INVALID_IF(!cJSON_IsArray(src.impl)) + dst->impl = src.impl; return SWONResultValid; } -size_t swon_get_array_size(swon_t json) { - if (cJSON_IsNull(json.impl) || !cJSON_IsArray(json.impl)) return 0; - return cJSON_GetArraySize(json.impl); +size_t swon_get_array_size(swon_t src) { + if (cJSON_IsNull(src.impl) || !cJSON_IsArray(src.impl)) return 0; + return cJSON_GetArraySize(src.impl); } -swon_result swon_get_array_item(swon_t json, int index, swon_t *object) { - SWON_RETURN_IF_NULL(json.impl) - SWON_RETURN_INVALID_IF(!cJSON_IsArray(json.impl)) - object->impl = cJSON_GetArrayItem(json.impl, index); +swon_result swon_get_array_item(swon_t *object, swon_t src, int index) { + SWON_RETURN_IF_NULL(src.impl) + SWON_RETURN_INVALID_IF(!cJSON_IsArray(src.impl)) + object->impl = cJSON_GetArrayItem(src.impl, index); return SWONResultValid; } -swon_t swon_get_map_first(swon_t json) { - swon_t child = { ((cJSON *)json.impl)->child }; +swon_t swon_get_map_first(swon_t src) { + swon_t child = { ((cJSON *)src.impl)->child }; return child; } -bool swon_get_map_exists(swon_t json) { - return (cJSON *)json.impl != NULL; +bool swon_get_map_exists(swon_t src) { + return (cJSON *)src.impl != NULL; } -const char *swon_get_map_key(swon_t json) { - if (!json.impl) return NULL; - return ((cJSON *)json.impl)->string; +const char *swon_get_map_key(swon_t src) { + if (!src.impl) return NULL; + return ((cJSON *)src.impl)->string; } -swon_t swon_get_map_next(swon_t json) { - swon_t next = { ((cJSON *)json.impl)->next }; +swon_t swon_get_map_next(swon_t src) { + swon_t next = { ((cJSON *)src.impl)->next }; return next; } +bool swon_create_array(swon_t *dst) { + dst->impl = cJSON_CreateArray(); + return dst->impl; +} + +bool swon_array_add_item(swon_t *dst, swon_t item) { + return cJSON_AddItemToArray(dst->impl, item.impl); +} + +bool swon_create_object(swon_t *dst) { + dst->impl = cJSON_CreateObject(); + return dst->impl; +} + +bool swon_object_add_item(swon_t *dst, const char *field, swon_t item) { + return cJSON_AddItemToObject(dst->impl, field, item.impl); +} + +bool swon_create_number(swon_t *dst, double value) { + dst->impl = cJSON_CreateNumber(value); + return dst; +} + +bool swon_create_integer(swon_t *dst, int value) { + dst->impl = cJSON_CreateNumber(value); + return dst; +} + +bool swon_create_bool(swon_t *dst, bool value) { + dst->impl = cJSON_CreateBool(value); + return dst; +} + +bool swon_create_string(swon_t *dst, const char *value) { + dst->impl = cJSON_CreateString(value); + return dst; +} + const char *swon_error_ptr() { return cJSON_GetErrorPtr(); } diff --git a/Tests/SWONTests/SWONTests.swift b/Tests/SWONTests/SWONTests.swift index 7810570..3b8182b 100644 --- a/Tests/SWONTests/SWONTests.swift +++ b/Tests/SWONTests/SWONTests.swift @@ -8,12 +8,70 @@ import Testing struct SWONTests { @Test(arguments: ["populated", "minimal", "partial"]) - func featureParity(filename: String) throws { + func decodingParity(filename: String) throws { let json = try jsonString(fromFileNamed: filename) let parsed = try ComplexStruct.withSWON(json) let foundationParsed = try ComplexStruct.withFoundation(json) #expect(parsed == foundationParsed) } + + @Test + func encodingEnums() throws { + #expect(try Size.medium.toJSON() == "1") + #expect(try Status.inactive.toJSON() == "\"inactive\"") + } + + @Test + func encodingStruct() throws { + @SWON + struct LocalStruct { + let favoriteColor: String + let optionalSize: Int? + let statusHistory: [String] + let colorToStatus: [String: String] + let another: AnotherStruct? + let nestedStrings: [[String]] + let nestedColors: [[Color]] + } + @SWON + struct AnotherStruct { + let one: Int + let two: Double + } + let sut = try LocalStruct(fromJSON: """ +{ + "favoriteColor": "green", + "optionalSize": 1, + "statusHistory": ["inactive", "pending"], + "colorToStatus": {"green": "inactive"}, + "optionalColorArray": null, + "another": { + "one": 1, + "two": 2.0 + }, + "nestedStrings": [ + ["one", "two"], + ["four", "five", "six"] + ], + "nestedColors": [ + ["green", "blue"], + ["red", "red", "green"] + ] +} +""") + print(try sut.toJSON()) + } + + @Test(arguments: ["minimal", "partial", "populated"]) + func encodingReversibility(filename: String) throws { + let json = try jsonString(fromFileNamed: filename) + let parsed = try ComplexStruct.withSWON(json) + let encoded = try parsed.toJSON() + print(encoded) +// #expect(encoded == json) + let parsed2 = try ComplexStruct.withSWON(encoded) + #expect(parsed == parsed2) + } } func jsonString(fromFileNamed name: String) throws -> String { From 38ec06ae80ff9834fc27a7dd323bc71dd7f84150 Mon Sep 17 00:00:00 2001 From: Davide Date: Wed, 21 Jan 2026 13:28:54 +0100 Subject: [PATCH 2/7] Hide cJSON interfaces --- Sources/SWON_C/include/swon.h | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SWON_C/include/swon.h b/Sources/SWON_C/include/swon.h index 1c7fd91..3453e0f 100644 --- a/Sources/SWON_C/include/swon.h +++ b/Sources/SWON_C/include/swon.h @@ -8,7 +8,6 @@ #include #include -#include "cJSON.h" typedef struct { void *impl; From e79bda72cefe62785f36cb9b6c73bc09188afbd6 Mon Sep 17 00:00:00 2001 From: Davide Date: Wed, 21 Jan 2026 13:33:37 +0100 Subject: [PATCH 3/7] Reword error pointer to be a parsing error --- Sources/SWONMacros/Decoding.swift | 2 +- Sources/SWONMacros/Encoding.swift | 3 +-- Sources/SWON_C/include/swon.h | 4 ++-- Sources/SWON_C/swon.c | 8 ++++---- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/Sources/SWONMacros/Decoding.swift b/Sources/SWONMacros/Decoding.swift index 080aed4..c51cb48 100644 --- a/Sources/SWONMacros/Decoding.swift +++ b/Sources/SWONMacros/Decoding.swift @@ -101,7 +101,7 @@ struct SWONDecodeMacro: MemberMacro { var root = swon_t() defer { swon_free(&root) } guard swon_parse(&root, json) == SWONResultValid else { - let message = String(cString: swon_error_ptr()) + let message = String(cString: swon_parse_error_ptr()) throw SWONError.invalid("At \\(message)") } try self.init(fromSWON: root) diff --git a/Sources/SWONMacros/Encoding.swift b/Sources/SWONMacros/Encoding.swift index 619670c..306f8da 100644 --- a/Sources/SWONMacros/Encoding.swift +++ b/Sources/SWONMacros/Encoding.swift @@ -115,8 +115,7 @@ struct SWONEncodeMacro: MemberMacro { var item = try toSWON() defer { swon_free(&item) } guard let cjson = swon_encode(item) else { - let message = String(cString: swon_error_ptr()) - throw SWONError.invalid("At \\(message)") + throw SWONError.invalid("Unable to encode self") } let json = String(cString: cjson) swon_free_string(cjson) diff --git a/Sources/SWON_C/include/swon.h b/Sources/SWON_C/include/swon.h index 3453e0f..f75da91 100644 --- a/Sources/SWON_C/include/swon.h +++ b/Sources/SWON_C/include/swon.h @@ -20,6 +20,8 @@ typedef enum { } swon_result; swon_result swon_parse(swon_t *dst, const char *text); +const char *swon_parse_error_ptr(); + char *swon_encode(swon_t src); void swon_free_string(char *string); void swon_free(swon_t *dst); @@ -49,5 +51,3 @@ bool swon_create_number(swon_t *dst, double value); bool swon_create_integer(swon_t *dst, int value); bool swon_create_bool(swon_t *dst, bool value); bool swon_create_string(swon_t *dst, const char *value); - -const char *swon_error_ptr(); diff --git a/Sources/SWON_C/swon.c b/Sources/SWON_C/swon.c index 4435c39..d25570a 100644 --- a/Sources/SWON_C/swon.c +++ b/Sources/SWON_C/swon.c @@ -24,6 +24,10 @@ swon_result swon_parse(swon_t *dst, const char *text) { return SWONResultValid; } +const char *swon_parse_error_ptr() { + return cJSON_GetErrorPtr(); +} + char *swon_encode(swon_t src) { return cJSON_PrintUnformatted(src.impl); } @@ -149,7 +153,3 @@ bool swon_create_string(swon_t *dst, const char *value) { dst->impl = cJSON_CreateString(value); return dst; } - -const char *swon_error_ptr() { - return cJSON_GetErrorPtr(); -} From 873bdc0df983152c0aa61cf9860aa39bf7280972 Mon Sep 17 00:00:00 2001 From: Davide Date: Wed, 21 Jan 2026 14:08:54 +0100 Subject: [PATCH 4/7] Add test workflow --- .github/workflows/test.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e07bff7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: SwiftPM Tests + +on: + pull_request: + types: [ opened, synchronize ] + branches: + - "master" + paths-ignore: + - "README.md" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + run_swift_tests: + name: Run SwiftPM tests + runs-on: macos-26 + timeout-minutes: 10 + steps: + - uses: partout-io/action-prepare-xcode-build@master + - run: | + swift test From 8329df3547eb796f540db02a8ca1d1db2673a861 Mon Sep 17 00:00:00 2001 From: Davide Date: Wed, 21 Jan 2026 14:11:13 +0100 Subject: [PATCH 5/7] [ci skip] Comment diagnostic messages --- Sources/SWONMacros/Encoding.swift | 36 +++++++++++++++---------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Sources/SWONMacros/Encoding.swift b/Sources/SWONMacros/Encoding.swift index 306f8da..543894e 100644 --- a/Sources/SWONMacros/Encoding.swift +++ b/Sources/SWONMacros/Encoding.swift @@ -37,12 +37,12 @@ struct SWONEncodeMacro: MemberMacro { } let enumType = enumDecl.name.text let rawType = inheritedType.type.description - context.diagnose( - Diagnostic( - node: Syntax(node), - message: SWONMessage(message: "Raw type of enum \(enumType) is \(rawType)") - ) - ) +// context.diagnose( +// Diagnostic( +// node: Syntax(node), +// message: SWONMessage(message: "Raw type of enum \(enumType) is \(rawType)") +// ) +// ) switch rawType { case "String": assignments.append(""" @@ -72,12 +72,12 @@ struct SWONEncodeMacro: MemberMacro { guard let type = prop.bindings.first?.typeAnnotation?.type else { continue } - context.diagnose( - Diagnostic( - node: Syntax(node), - message: SWONMessage(message: "Assigning \(field) of type \(type)") - ) - ) +// context.diagnose( +// Diagnostic( +// node: Syntax(node), +// message: SWONMessage(message: "Assigning \(field) of type \(type)") +// ) +// ) let stmts = mapItem( to: type.decodedType(context: context), varName: field, @@ -89,12 +89,12 @@ struct SWONEncodeMacro: MemberMacro { } assignments.append("return root") } - context.diagnose( - Diagnostic( - node: Syntax(node), - message: SWONMessage(message: "Assignments: \(assignments)") - ) - ) +// context.diagnose( +// Diagnostic( +// node: Syntax(node), +// message: SWONMessage(message: "Assignments: \(assignments)") +// ) +// ) return [ DeclSyntax(stringLiteral: """ func toSWON() throws -> swon_t { From b464b15a69954aaa1f7ec57e89800db066d4deb5 Mon Sep 17 00:00:00 2001 From: Davide Date: Wed, 21 Jan 2026 14:14:06 +0100 Subject: [PATCH 6/7] Test without Partout actions --- .github/workflows/test.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e07bff7..36be552 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,9 @@ jobs: runs-on: macos-26 timeout-minutes: 10 steps: - - uses: partout-io/action-prepare-xcode-build@master + - uses: actions/checkout@v4 + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 26.1 - run: | swift test From 01aacca85f332d54af1d791f6d27e99772023de5 Mon Sep 17 00:00:00 2001 From: Davide Date: Wed, 21 Jan 2026 14:23:15 +0100 Subject: [PATCH 7/7] Pack bool args into TypeCategory --- Sources/SWONMacros/Encoding.swift | 50 ++++++++++++++++++------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/Sources/SWONMacros/Encoding.swift b/Sources/SWONMacros/Encoding.swift index 543894e..4124495 100644 --- a/Sources/SWONMacros/Encoding.swift +++ b/Sources/SWONMacros/Encoding.swift @@ -83,7 +83,7 @@ struct SWONEncodeMacro: MemberMacro { varName: field, parentName: "root", nesting: 0, - isOptional: type.isOptional + category: type.isOptional ? .optional : .none ) assignments.append(contentsOf: stmts) } @@ -132,12 +132,10 @@ private extension SWONEncodeMacro { varName: String, parentName: String, nesting: Int, - isOptional: Bool, - isCollection: Bool = false, - isPair: Bool = false + category: TypeCategory ) -> [String] { var stmts: [String] = [] - if isOptional { + if category == .optional { stmts.append("if let \(varName) {") } switch type { @@ -157,7 +155,7 @@ private extension SWONEncodeMacro { varName: varName, parentName: parentName, nesting: nesting, - isOptional: true + category: .optional ) case .array(let elementType): let elementName = "element\(nesting)" @@ -172,8 +170,7 @@ private extension SWONEncodeMacro { varName: elementName, parentName: parentName, nesting: nesting + 1, - isOptional: false, - isCollection: true + category: .array ) case .dictionary: return mapItem( @@ -181,8 +178,7 @@ private extension SWONEncodeMacro { varName: elementName, parentName: parentName, nesting: nesting + 1, - isOptional: false, - isCollection: true + category: .array ) case .scalar: return elementType.statements( @@ -197,7 +193,7 @@ private extension SWONEncodeMacro { guard swon_create_array(&\(varName)JSON) else { throw SWONError.invalid("Unable to create array in \(parentName) (\(varName)JSON)") } - for element\(nesting) in \(varName)\(isPair ? ".value" : "") { + for element\(nesting) in \(varName)\(category == .dictionary ? ".value" : "") { do { \(itemExpr.joined(separator: "\n")) guard swon_array_add_item(&\(varName)JSON, \(itemName)) else { @@ -210,7 +206,7 @@ private extension SWONEncodeMacro { } } """) - if !isCollection { + if !category.isCollection { stmts.append(""" guard swon_object_add_item(&\(parentName), "\(varName)", \(varName)JSON) else { swon_free(&\(varName)JSON) @@ -234,9 +230,7 @@ private extension SWONEncodeMacro { varName: elementName, parentName: parentName, nesting: nesting + 1, - isOptional: false, - isCollection: true, - isPair: true + category: .dictionary ) case .dictionary: return mapItem( @@ -244,9 +238,7 @@ private extension SWONEncodeMacro { varName: elementName, parentName: parentName, nesting: nesting + 1, - isOptional: false, - isCollection: true, - isPair: true + category: .dictionary ) case .scalar: return valueType.statements( @@ -261,7 +253,7 @@ private extension SWONEncodeMacro { guard swon_create_object(&\(varName)JSON) else { throw SWONError.invalid("Unable to create object in \(parentName)") } - for element\(nesting) in \(varName)\(isPair ? ".value" : "") { + for element\(nesting) in \(varName)\(category == .dictionary ? ".value" : "") { \(itemExpr.joined(separator: "\n")) do { guard swon_object_add_item(&\(varName)JSON, element\(nesting).key, \(itemName)) else { @@ -274,7 +266,7 @@ private extension SWONEncodeMacro { } } """) - if !isCollection { + if !category.isCollection { stmts.append(""" guard swon_object_add_item(&\(parentName), \"\(varName)\", \(varName)JSON) else { swon_free(&\(varName)JSON) @@ -283,7 +275,7 @@ private extension SWONEncodeMacro { """) } } - if isOptional { + if category == .optional { stmts.append("}") } return stmts @@ -316,3 +308,19 @@ private extension DecodedType { return stmts } } + +private enum TypeCategory { + case none + case optional + case array + case dictionary + + var isCollection: Bool { + switch self { + case .array, .dictionary: + return true + default: + return false + } + } +}