diff --git a/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md b/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md index 7e96cd60..3ffb09af 100644 --- a/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md +++ b/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md @@ -263,9 +263,13 @@ Computed properties are not included in generated schemas. The ``Schemable()`` macro can also be applied to Swift enums. The enum cases will be expanded as string literals in the schema. Only strings are supported in macro generation currently. To support other types, you must manually implement the schema. +#### Simple Enums + +For enums without raw values, the schema uses the case names: + ```swift @Schemable -enum TemperatureType: String { +enum TemperatureType { case fahrenheit case celsius case kelvin @@ -275,7 +279,7 @@ enum TemperatureType: String { will expand to: ```swift -enum TemperatureType: String { +enum TemperatureType { case fahrenheit case celsius case kelvin @@ -300,6 +304,51 @@ enum TemperatureType: String { } ``` +#### String-Backed Enums + +For enums with String raw values, the schema uses the **raw values**, ensuring compatibility with Swift's `Codable`: + +```swift +@Schemable +enum Size: String { + case small = "sm" + case medium = "md" + case large = "lg" +} +``` + +will expand to: + +```swift +enum Size: String { + case small = "sm" + case medium = "md" + case large = "lg" + + // Auto-generated schema ↴ + static var schema: some JSONSchemaComponent { + JSONString() + .enumValues { + "sm" + "md" + "lg" + } + .compactMap { string in + switch string { + case "sm": return Size.small + case "md": return Size.medium + case "lg": return Size.large + default: return nil + } + } + } +} +``` + +This behavior matches Swift's `Codable`, which encodes and decodes using the raw values. The generated schema will correctly validate JSON data that can be decoded with Swift's standard `JSONDecoder`. + +#### Enums with Associated Values + If any of the enum cases have an associated value, the macro will instead expand using the `OneOf` ``JSONComposition/OneOf/init(_:)`` builder. ```swift diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift index 5b1435ab..e37836b3 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift @@ -3,11 +3,22 @@ import SwiftSyntax import SwiftSyntaxBuilder import SwiftSyntaxMacros +extension String { + /// Removes backticks from Swift identifiers (e.g., "`default`" → "default") + func trimmingBackticks() -> String { + if self.hasPrefix("`") && self.hasSuffix("`") { + return String(self.dropFirst().dropLast()) + } + return self + } +} + struct EnumSchemaGenerator { let declModifier: DeclModifierSyntax? let name: TokenSyntax let members: MemberBlockItemListSyntax let attributes: AttributeListSyntax + let isStringBacked: Bool init(fromEnum enumDecl: EnumDeclSyntax, accessLevel: String? = nil) { // Use provided access level if available, otherwise use the declaration's modifier @@ -23,10 +34,17 @@ struct EnumSchemaGenerator { name = enumDecl.name.trimmed members = enumDecl.memberBlock.members attributes = enumDecl.attributes + + // Check if enum inherits from String + isStringBacked = + enumDecl.inheritanceClause?.inheritedTypes + .contains { type in + type.type.as(IdentifierTypeSyntax.self)?.name.text == "String" + } ?? false } func makeSchema() -> DeclSyntax { - let schemableCases = members.schemableEnumCases() + let schemableCases = members.schemableEnumCases(isStringBacked: isStringBacked) let casesWithoutAssociatedValues = schemableCases.filter { $0.associatedValues == nil } let casesWithAssociatedValues = schemableCases.filter { $0.associatedValues != nil } @@ -73,19 +91,22 @@ struct EnumSchemaGenerator { let statements = casesWithoutAssociatedValues.compactMap { $0.generateSchema() } let statementList = CodeBlockItemListSyntax(statements, separator: .newline) - var switchCases = casesWithoutAssociatedValues.map(\.identifier) - .map { identifier -> SwitchCaseSyntax in - """ - case \"\(identifier)\": - return Self.\(identifier) + var switchCases = + casesWithoutAssociatedValues + .map { enumCase -> SwitchCaseSyntax in + // Use raw value if present, otherwise use case name (without backticks) + let matchValue = enumCase.rawValue ?? enumCase.identifier.text.trimmingBackticks() + return """ + case "\(raw: matchValue)": + return Self.\(enumCase.identifier) - """ + """ } switchCases.append("default: return nil") let switchCaseList = SwitchCaseListSyntax(switchCases.map { .switchCase($0) }) return """ - JSONString() + JSONString() .enumValues { \(statementList) } diff --git a/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift b/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift index af55318b..32527d75 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift @@ -3,16 +3,37 @@ import SwiftSyntax struct SchemableEnumCase { let identifier: TokenSyntax let associatedValues: EnumCaseParameterListSyntax? + let rawValue: String? - init(enumCaseDecl: EnumCaseDeclSyntax, caseElement: EnumCaseElementSyntax) { + init(enumCaseDecl: EnumCaseDeclSyntax, caseElement: EnumCaseElementSyntax, isStringBacked: Bool) { identifier = caseElement.name.trimmed associatedValues = caseElement.parameterClause?.parameters + + // Extract raw value if present (for String-backed enums) + if let rawValueExpr = caseElement.rawValue?.value.as(StringLiteralExprSyntax.self) { + // Explicit raw value + rawValue = rawValueExpr.segments + .compactMap { segment -> String? in + if case .stringSegment(let stringSegment) = segment { + return stringSegment.content.text + } + return nil + } + .joined() + } else if isStringBacked { + // Implicit raw value: use the case name (without backticks) for String-backed enums + rawValue = identifier.text.trimmingBackticks() + } else { + rawValue = nil + } } func generateSchema() -> CodeBlockItemSyntax? { guard let associatedValues else { + // Use raw value if present, otherwise use case name (without backticks) + let enumValue = rawValue ?? identifier.text.trimmingBackticks() return """ - "\(identifier)" + "\(raw: enumValue)" """ } let properties: [CodeBlockItemSyntax] = associatedValues.enumerated() diff --git a/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift b/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift index 843c0352..6342a5dc 100644 --- a/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift +++ b/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift @@ -261,9 +261,12 @@ extension MemberBlockItemListSyntax { .compactMap(SchemableMember.init) } - func schemableEnumCases() -> [SchemableEnumCase] { + func schemableEnumCases(isStringBacked: Bool) -> [SchemableEnumCase] { self.compactMap { $0.decl.as(EnumCaseDeclSyntax.self) } - .flatMap { caseDecl in caseDecl.elements.map { (caseDecl, $0) } }.map(SchemableEnumCase.init) + .flatMap { caseDecl in caseDecl.elements.map { (caseDecl, $0) } } + .map { + SchemableEnumCase(enumCaseDecl: $0.0, caseElement: $0.1, isStringBacked: isStringBacked) + } } /// Extracts CodingKeys mapping from a CodingKeys enum if present diff --git a/Tests/JSONSchemaIntegrationTests/BacktickEnumIntegrationTests.swift b/Tests/JSONSchemaIntegrationTests/BacktickEnumIntegrationTests.swift new file mode 100644 index 00000000..49f39e29 --- /dev/null +++ b/Tests/JSONSchemaIntegrationTests/BacktickEnumIntegrationTests.swift @@ -0,0 +1,86 @@ +import Foundation +import JSONSchema +import JSONSchemaBuilder +import Testing + +@Suite struct BacktickEnumIntegrationTests { + + @Schemable + enum Keywords { + case `default` + case `public` + case normal + } + + @Schemable + enum KeywordsWithRawValues: String { + case `default` = "default_value" + case `public` + case normal + } + + @Test + func backtickCasesWithoutRawValuesSchema() throws { + let schema = Keywords.schema.definition() + let jsonData = try JSONEncoder().encode(schema) + let jsonValue = try #require(try JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) + + // Should have enum values without backticks + let enumValues = try #require(jsonValue["enum"] as? [String]) + #expect(enumValues.contains("default")) + #expect(enumValues.contains("public")) + #expect(enumValues.contains("normal")) + + // Should NOT contain backticks + #expect(!enumValues.contains("`default`")) + #expect(!enumValues.contains("`public`")) + } + + @Test + func backtickCasesWithRawValuesSchema() throws { + let schema = KeywordsWithRawValues.schema.definition() + let jsonData = try JSONEncoder().encode(schema) + let jsonValue = try #require(try JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) + + // Should have enum values using raw values + let enumValues = try #require(jsonValue["enum"] as? [String]) + #expect(enumValues.contains("default_value")) // Custom raw value + #expect(enumValues.contains("public")) // Implicit raw value + #expect(enumValues.contains("normal")) // Implicit raw value + + // Should NOT contain backticks or case names for custom raw value + #expect(!enumValues.contains("`default`")) + #expect(!enumValues.contains("default")) + } + + @Test + func backtickCasesParsingWithoutRawValues() throws { + // Test that parsing works correctly + let jsonDefault = "\"default\"" + let jsonPublic = "\"public\"" + + let parsedDefault = try Keywords.schema.parseAndValidate(instance: jsonDefault) + let parsedPublic = try Keywords.schema.parseAndValidate(instance: jsonPublic) + + #expect(parsedDefault == .default) + #expect(parsedPublic == .public) + } + + @Test + func backtickCasesParsingWithRawValues() throws { + // Test that parsing works correctly with raw values + let jsonDefault = "\"default_value\"" + let jsonPublic = "\"public\"" + + let parsedDefault = try KeywordsWithRawValues.schema.parseAndValidate(instance: jsonDefault) + let parsedPublic = try KeywordsWithRawValues.schema.parseAndValidate(instance: jsonPublic) + + #expect(parsedDefault == .default) + #expect(parsedPublic == .public) + + // Should NOT parse case names when raw values exist + #expect(throws: Error.self) { + _ = try KeywordsWithRawValues.schema.parseAndValidate(instance: "\"default\"") + } + } +} diff --git a/Tests/JSONSchemaIntegrationTests/PollExampleTests.swift b/Tests/JSONSchemaIntegrationTests/PollExampleTests.swift index 3272e8c4..c81b171d 100644 --- a/Tests/JSONSchemaIntegrationTests/PollExampleTests.swift +++ b/Tests/JSONSchemaIntegrationTests/PollExampleTests.swift @@ -182,7 +182,7 @@ struct PollExampleTests { "entertainment": { "_0": { "genre": "movies", - "ageRating": "pg13" + "ageRating": "Parental Guidance Suggested 13+" } } } diff --git a/Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/defintion.1.json b/Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/defintion.1.json index e12dd13d..d3924308 100644 --- a/Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/defintion.1.json +++ b/Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/defintion.1.json @@ -43,10 +43,10 @@ "properties" : { "ageRating" : { "enum" : [ - "g", - "pg", - "pg13", - "r" + "General Audience", + "Parental Guidance Suggested", + "Parental Guidance Suggested 13+", + "Restricted" ], "type" : "string" }, diff --git a/Tests/JSONSchemaMacroTests/BacktickEnumTests.swift b/Tests/JSONSchemaMacroTests/BacktickEnumTests.swift new file mode 100644 index 00000000..301fdafe --- /dev/null +++ b/Tests/JSONSchemaMacroTests/BacktickEnumTests.swift @@ -0,0 +1,99 @@ +import JSONSchemaMacro +import SwiftSyntaxMacros +import Testing + +@Suite struct BacktickEnumTests { + let testMacros: [String: Macro.Type] = ["Schemable": SchemableMacro.self] + + @Test func backtickCasesWithoutRawValues() { + assertMacroExpansion( + """ + @Schemable + enum Keywords { + case `default` + case `public` + case normal + } + """, + expandedSource: """ + enum Keywords { + case `default` + case `public` + case normal + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONString() + .enumValues { + "default" + "public" + "normal" + } + .compactMap { + switch $0 { + case "default": + return Self.`default` + case "public": + return Self.`public` + case "normal": + return Self.normal + default: + return nil + } + } + } + } + + extension Keywords: Schemable { + } + """, + macros: testMacros + ) + } + + @Test func backtickCasesWithRawValues() { + assertMacroExpansion( + """ + @Schemable + enum Keywords: String { + case `default` = "default_value" + case `public` + case normal + } + """, + expandedSource: """ + enum Keywords: String { + case `default` = "default_value" + case `public` + case normal + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONString() + .enumValues { + "default_value" + "public" + "normal" + } + .compactMap { + switch $0 { + case "default_value": + return Self.`default` + case "public": + return Self.`public` + case "normal": + return Self.normal + default: + return nil + } + } + } + } + + extension Keywords: Schemable { + } + """, + macros: testMacros + ) + } +}