Skip to content

Commit 4339a61

Browse files
authored
Fix concurrency isolation for Generable macro expansion (#28)
* Add nonisolated modifier to Generable extension declaration * Add memberwise initializer for Generable types * Add test coverage for Generable macro expansion * Simplify redundant conditionals
1 parent c1796d7 commit 4339a61

File tree

2 files changed

+133
-15
lines changed

2 files changed

+133
-15
lines changed

Sources/AnyLanguageModelMacros/GenerableMacro.swift

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro {
1919

2020
return [
2121
generateRawContentProperty(),
22+
generateMemberwiseInit(properties: properties),
2223
generateInitFromGeneratedContent(structName: structName, properties: properties),
2324
generateGeneratedContentProperty(
2425
structName: structName,
@@ -69,7 +70,10 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro {
6970
conformingTo protocols: [TypeSyntax],
7071
in context: some MacroExpansionContext
7172
) throws -> [ExtensionDeclSyntax] {
73+
let nonisolatedModifier = DeclModifierSyntax(name: .keyword(.nonisolated))
74+
7275
let extensionDecl = ExtensionDeclSyntax(
76+
modifiers: DeclModifierListSyntax([nonisolatedModifier]),
7377
extendedType: type,
7478
inheritanceClause: InheritanceClauseSyntax(
7579
inheritedTypes: InheritedTypeListSyntax([
@@ -247,6 +251,91 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro {
247251
)
248252
}
249253

254+
private static func generateMemberwiseInit(properties: [PropertyInfo]) -> DeclSyntax {
255+
if properties.isEmpty {
256+
return DeclSyntax(
257+
stringLiteral: """
258+
nonisolated public init() {
259+
self._rawGeneratedContent = GeneratedContent(kind: .structure(properties: [:], orderedKeys: []))
260+
}
261+
"""
262+
)
263+
}
264+
265+
let parameters = properties.map { prop in
266+
"\(prop.name): \(prop.type)"
267+
}.joined(separator: ", ")
268+
269+
let assignments = properties.map { prop in
270+
"self.\(prop.name) = \(prop.name)"
271+
}.joined(separator: "\n ")
272+
273+
let propertyConversions = properties.map { prop in
274+
let propName = prop.name
275+
let propType = prop.type
276+
277+
if propType.hasSuffix("?") {
278+
let baseType = String(propType.dropLast())
279+
if baseType == "String" {
280+
return
281+
"properties[\"\(propName)\"] = \(propName).map { GeneratedContent($0) } ?? GeneratedContent(kind: .null)"
282+
} else if baseType == "Int" || baseType == "Double" || baseType == "Float"
283+
|| baseType == "Bool" || baseType == "Decimal"
284+
{
285+
return
286+
"properties[\"\(propName)\"] = \(propName).map { $0.generatedContent } ?? GeneratedContent(kind: .null)"
287+
} else if isDictionaryType(baseType) {
288+
return
289+
"properties[\"\(propName)\"] = \(propName).map { $0.generatedContent } ?? GeneratedContent(kind: .null)"
290+
} else if baseType.hasPrefix("[") && baseType.hasSuffix("]") {
291+
return
292+
"properties[\"\(propName)\"] = \(propName).map { GeneratedContent(elements: $0) } ?? GeneratedContent(kind: .null)"
293+
} else {
294+
return """
295+
if let value = \(propName) {
296+
properties["\(propName)"] = value.generatedContent
297+
} else {
298+
properties["\(propName)"] = GeneratedContent(kind: .null)
299+
}
300+
"""
301+
}
302+
} else if isDictionaryType(propType) {
303+
return "properties[\"\(propName)\"] = \(propName).generatedContent"
304+
} else if propType.hasPrefix("[") && propType.hasSuffix("]") {
305+
return "properties[\"\(propName)\"] = GeneratedContent(elements: \(propName))"
306+
} else {
307+
switch propType {
308+
case "String":
309+
return "properties[\"\(propName)\"] = GeneratedContent(\(propName))"
310+
case "Int", "Double", "Float", "Bool", "Decimal":
311+
return "properties[\"\(propName)\"] = \(propName).generatedContent"
312+
default:
313+
return "properties[\"\(propName)\"] = \(propName).generatedContent"
314+
}
315+
}
316+
}.joined(separator: "\n ")
317+
318+
let orderedKeys = properties.map { "\"\($0.name)\"" }.joined(separator: ", ")
319+
320+
return DeclSyntax(
321+
stringLiteral: """
322+
nonisolated public init(\(parameters)) {
323+
\(assignments)
324+
325+
var properties: [String: GeneratedContent] = [:]
326+
\(propertyConversions)
327+
328+
self._rawGeneratedContent = GeneratedContent(
329+
kind: .structure(
330+
properties: properties,
331+
orderedKeys: [\(orderedKeys)]
332+
)
333+
)
334+
}
335+
"""
336+
)
337+
}
338+
250339
private static func generateInitFromGeneratedContent(
251340
structName: String,
252341
properties: [PropertyInfo]
@@ -457,16 +546,7 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro {
457546
} else if isDictionaryType(propType) {
458547
return "properties[\"\(propName)\"] = \(propName).generatedContent"
459548
} else if propType.hasPrefix("[") && propType.hasSuffix("]") {
460-
let elementType = String(propType.dropFirst().dropLast())
461-
if elementType == "String" {
462-
return "properties[\"\(propName)\"] = GeneratedContent(elements: \(propName))"
463-
} else if elementType == "Int" || elementType == "Double" || elementType == "Bool"
464-
|| elementType == "Float" || elementType == "Decimal"
465-
{
466-
return "properties[\"\(propName)\"] = GeneratedContent(elements: \(propName))"
467-
} else {
468-
return "properties[\"\(propName)\"] = GeneratedContent(elements: \(propName))"
469-
}
549+
return "properties[\"\(propName)\"] = GeneratedContent(elements: \(propName))"
470550
} else {
471551
switch propType {
472552
case "String":

Tests/AnyLanguageModelTests/GenerableMacroTests.swift

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,19 @@ private struct TestStructWithNewlines {
2525
var field: String
2626
}
2727

28+
@Generable
29+
struct TestArguments {
30+
@Guide(description: "A name field")
31+
var name: String
32+
33+
@Guide(description: "An age field")
34+
var age: Int
35+
}
36+
2837
@Suite("Generable Macro")
2938
struct GenerableMacroTests {
30-
@Test func multilineGuideDescription() async throws {
39+
@Test("@Guide description with multiline string")
40+
func multilineGuideDescription() async throws {
3141
let schema = TestStructWithMultilineDescription.generationSchema
3242
let encoder = JSONEncoder()
3343
let jsonData = try encoder.encode(schema)
@@ -41,7 +51,8 @@ struct GenerableMacroTests {
4151
#expect(decodedSchema.debugDescription.contains("object"))
4252
}
4353

44-
@Test func guideDescriptionWithSpecialCharacters() async throws {
54+
@Test("@Guide description with special characters")
55+
func guideDescriptionWithSpecialCharacters() async throws {
4556
let schema = TestStructWithSpecialCharacters.generationSchema
4657
let encoder = JSONEncoder()
4758
let jsonData = try encoder.encode(schema)
@@ -57,7 +68,8 @@ struct GenerableMacroTests {
5768
#expect(decodedSchema.debugDescription.contains("object"))
5869
}
5970

60-
@Test func guideDescriptionWithNewlines() async throws {
71+
@Test("@Guide description with newlines")
72+
func guideDescriptionWithNewlines() async throws {
6173
let schema = TestStructWithNewlines.generationSchema
6274
let encoder = JSONEncoder()
6375
let jsonData = try encoder.encode(schema)
@@ -78,9 +90,9 @@ struct GenerableMacroTests {
7890
var field: String
7991
}
8092

81-
/// Test to verify @Generable works correctly with MainActor isolation.
8293
@MainActor
83-
@Test func mainActorIsolation() async throws {
94+
@Test("@MainActor isolation")
95+
func mainActorIsolation() async throws {
8496
let generatedContent = GeneratedContent(properties: [
8597
"field": "test value"
8698
])
@@ -97,4 +109,30 @@ struct GenerableMacroTests {
97109
let partiallyGenerated = instance.asPartiallyGenerated()
98110
#expect(partiallyGenerated.field == "test value")
99111
}
112+
113+
@Test("Memberwise initializer")
114+
func memberwiseInit() throws {
115+
// This is the natural Swift way to create instances
116+
let args = TestArguments(name: "Alice", age: 30)
117+
118+
#expect(args.name == "Alice")
119+
#expect(args.age == 30)
120+
121+
// The generatedContent should also be properly populated
122+
let content = args.generatedContent
123+
#expect(content.jsonString.contains("Alice"))
124+
#expect(content.jsonString.contains("30"))
125+
}
126+
127+
@Test("Create instance from GeneratedContent")
128+
func fromGeneratedContent() throws {
129+
let content = GeneratedContent(properties: [
130+
"name": GeneratedContent("Bob"),
131+
"age": GeneratedContent(kind: .number(25)),
132+
])
133+
134+
let args = try TestArguments(content)
135+
#expect(args.name == "Bob")
136+
#expect(args.age == 25)
137+
}
100138
}

0 commit comments

Comments
 (0)