diff --git a/Sources/AnyLanguageModelMacros/GenerableMacro.swift b/Sources/AnyLanguageModelMacros/GenerableMacro.swift index b1a008ef..a44707e7 100644 --- a/Sources/AnyLanguageModelMacros/GenerableMacro.swift +++ b/Sources/AnyLanguageModelMacros/GenerableMacro.swift @@ -19,6 +19,7 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro { return [ generateRawContentProperty(), + generateMemberwiseInit(properties: properties), generateInitFromGeneratedContent(structName: structName, properties: properties), generateGeneratedContentProperty( structName: structName, @@ -69,7 +70,10 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro { conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { + let nonisolatedModifier = DeclModifierSyntax(name: .keyword(.nonisolated)) + let extensionDecl = ExtensionDeclSyntax( + modifiers: DeclModifierListSyntax([nonisolatedModifier]), extendedType: type, inheritanceClause: InheritanceClauseSyntax( inheritedTypes: InheritedTypeListSyntax([ @@ -247,6 +251,91 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro { ) } + private static func generateMemberwiseInit(properties: [PropertyInfo]) -> DeclSyntax { + if properties.isEmpty { + return DeclSyntax( + stringLiteral: """ + nonisolated public init() { + self._rawGeneratedContent = GeneratedContent(kind: .structure(properties: [:], orderedKeys: [])) + } + """ + ) + } + + let parameters = properties.map { prop in + "\(prop.name): \(prop.type)" + }.joined(separator: ", ") + + let assignments = properties.map { prop in + "self.\(prop.name) = \(prop.name)" + }.joined(separator: "\n ") + + let propertyConversions = properties.map { prop in + let propName = prop.name + let propType = prop.type + + if propType.hasSuffix("?") { + let baseType = String(propType.dropLast()) + if baseType == "String" { + return + "properties[\"\(propName)\"] = \(propName).map { GeneratedContent($0) } ?? GeneratedContent(kind: .null)" + } else if baseType == "Int" || baseType == "Double" || baseType == "Float" + || baseType == "Bool" || baseType == "Decimal" + { + return + "properties[\"\(propName)\"] = \(propName).map { $0.generatedContent } ?? GeneratedContent(kind: .null)" + } else if isDictionaryType(baseType) { + return + "properties[\"\(propName)\"] = \(propName).map { $0.generatedContent } ?? GeneratedContent(kind: .null)" + } else if baseType.hasPrefix("[") && baseType.hasSuffix("]") { + return + "properties[\"\(propName)\"] = \(propName).map { GeneratedContent(elements: $0) } ?? GeneratedContent(kind: .null)" + } else { + return """ + if let value = \(propName) { + properties["\(propName)"] = value.generatedContent + } else { + properties["\(propName)"] = GeneratedContent(kind: .null) + } + """ + } + } else if isDictionaryType(propType) { + return "properties[\"\(propName)\"] = \(propName).generatedContent" + } else if propType.hasPrefix("[") && propType.hasSuffix("]") { + return "properties[\"\(propName)\"] = GeneratedContent(elements: \(propName))" + } else { + switch propType { + case "String": + return "properties[\"\(propName)\"] = GeneratedContent(\(propName))" + case "Int", "Double", "Float", "Bool", "Decimal": + return "properties[\"\(propName)\"] = \(propName).generatedContent" + default: + return "properties[\"\(propName)\"] = \(propName).generatedContent" + } + } + }.joined(separator: "\n ") + + let orderedKeys = properties.map { "\"\($0.name)\"" }.joined(separator: ", ") + + return DeclSyntax( + stringLiteral: """ + nonisolated public init(\(parameters)) { + \(assignments) + + var properties: [String: GeneratedContent] = [:] + \(propertyConversions) + + self._rawGeneratedContent = GeneratedContent( + kind: .structure( + properties: properties, + orderedKeys: [\(orderedKeys)] + ) + ) + } + """ + ) + } + private static func generateInitFromGeneratedContent( structName: String, properties: [PropertyInfo] @@ -457,16 +546,7 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro { } else if isDictionaryType(propType) { return "properties[\"\(propName)\"] = \(propName).generatedContent" } else if propType.hasPrefix("[") && propType.hasSuffix("]") { - let elementType = String(propType.dropFirst().dropLast()) - if elementType == "String" { - return "properties[\"\(propName)\"] = GeneratedContent(elements: \(propName))" - } else if elementType == "Int" || elementType == "Double" || elementType == "Bool" - || elementType == "Float" || elementType == "Decimal" - { - return "properties[\"\(propName)\"] = GeneratedContent(elements: \(propName))" - } else { - return "properties[\"\(propName)\"] = GeneratedContent(elements: \(propName))" - } + return "properties[\"\(propName)\"] = GeneratedContent(elements: \(propName))" } else { switch propType { case "String": diff --git a/Tests/AnyLanguageModelTests/GenerableMacroTests.swift b/Tests/AnyLanguageModelTests/GenerableMacroTests.swift index 509e9425..1e6c12e7 100644 --- a/Tests/AnyLanguageModelTests/GenerableMacroTests.swift +++ b/Tests/AnyLanguageModelTests/GenerableMacroTests.swift @@ -25,9 +25,19 @@ private struct TestStructWithNewlines { var field: String } +@Generable +struct TestArguments { + @Guide(description: "A name field") + var name: String + + @Guide(description: "An age field") + var age: Int +} + @Suite("Generable Macro") struct GenerableMacroTests { - @Test func multilineGuideDescription() async throws { + @Test("@Guide description with multiline string") + func multilineGuideDescription() async throws { let schema = TestStructWithMultilineDescription.generationSchema let encoder = JSONEncoder() let jsonData = try encoder.encode(schema) @@ -41,7 +51,8 @@ struct GenerableMacroTests { #expect(decodedSchema.debugDescription.contains("object")) } - @Test func guideDescriptionWithSpecialCharacters() async throws { + @Test("@Guide description with special characters") + func guideDescriptionWithSpecialCharacters() async throws { let schema = TestStructWithSpecialCharacters.generationSchema let encoder = JSONEncoder() let jsonData = try encoder.encode(schema) @@ -57,7 +68,8 @@ struct GenerableMacroTests { #expect(decodedSchema.debugDescription.contains("object")) } - @Test func guideDescriptionWithNewlines() async throws { + @Test("@Guide description with newlines") + func guideDescriptionWithNewlines() async throws { let schema = TestStructWithNewlines.generationSchema let encoder = JSONEncoder() let jsonData = try encoder.encode(schema) @@ -78,9 +90,9 @@ struct GenerableMacroTests { var field: String } - /// Test to verify @Generable works correctly with MainActor isolation. @MainActor - @Test func mainActorIsolation() async throws { + @Test("@MainActor isolation") + func mainActorIsolation() async throws { let generatedContent = GeneratedContent(properties: [ "field": "test value" ]) @@ -97,4 +109,30 @@ struct GenerableMacroTests { let partiallyGenerated = instance.asPartiallyGenerated() #expect(partiallyGenerated.field == "test value") } + + @Test("Memberwise initializer") + func memberwiseInit() throws { + // This is the natural Swift way to create instances + let args = TestArguments(name: "Alice", age: 30) + + #expect(args.name == "Alice") + #expect(args.age == 30) + + // The generatedContent should also be properly populated + let content = args.generatedContent + #expect(content.jsonString.contains("Alice")) + #expect(content.jsonString.contains("30")) + } + + @Test("Create instance from GeneratedContent") + func fromGeneratedContent() throws { + let content = GeneratedContent(properties: [ + "name": GeneratedContent("Bob"), + "age": GeneratedContent(kind: .number(25)), + ]) + + let args = try TestArguments(content) + #expect(args.name == "Bob") + #expect(args.age == 25) + } }