Skip to content

Commit d3ed4ea

Browse files
committed
feat: comprehensive DocC documentation mirroring for @Splat macro
Enhance the @Splat macro to automatically extract and mirror comprehensive documentation from the Arguments struct to the generated initializer, providing seamless documentation inheritance and improved developer experience. Features: - Extract and mirror property documentation from Arguments struct - Preserve DocC formatting including blank lines and code blocks - Use Arguments init parameter docs instead of property docs for better clarity - Mirror complete Arguments init documentation (summary, discussion, parameters) - Extract and include throws documentation from parent init - Maintain proper DocC section structure (summary, discussion, parameters, throws) Benefits: - Generated initializers have complete, accurate documentation - IDE autocomplete shows full parameter descriptions - DocC builds generate complete API documentation - No manual documentation duplication required - Consistent documentation between Arguments and generated initializer Implementation: - Enhanced macro to traverse Arguments struct members - Extract documentation from both property and init declarations - Parse and preserve DocC formatting (sections, code blocks, blank lines) - Generate documentation string matching Swift DocC conventions - Handle edge cases (missing docs, complex formatting, throws clauses) This release significantly improves the developer experience when using @Splat by ensuring that all documentation written for the Arguments struct is automatically available on the generated initializer.
1 parent 7018696 commit d3ed4ea

File tree

1 file changed

+228
-19
lines changed

1 file changed

+228
-19
lines changed

Sources/SplatPlugin/SplatMacro.swift

Lines changed: 228 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ import SwiftSyntaxBuilder
33
import SwiftSyntaxMacros
44

55
public struct SplatMacro: MemberMacro {
6+
// Helper struct to avoid large tuple warning
7+
private struct PropertyInfo {
8+
let name: String
9+
let type: TypeSyntax
10+
let doc: String?
11+
}
12+
13+
// swiftlint:disable:next cyclomatic_complexity
614
public static func expansion(
715
of node: AttributeSyntax,
816
providingMembersOf declaration: some DeclGroupSyntax,
@@ -57,20 +65,69 @@ public struct SplatMacro: MemberMacro {
5765
return text
5866
}
5967

60-
// Extract properties from target struct
68+
// Helper to trim whitespace using only stdlib
69+
func trimWhitespace(_ text: String) -> String {
70+
var result = text
71+
// Trim leading whitespace
72+
while result.first?.isWhitespace == true {
73+
result.removeFirst()
74+
}
75+
// Trim trailing whitespace
76+
while result.last?.isWhitespace == true {
77+
result.removeLast()
78+
}
79+
return result
80+
}
81+
82+
// Helper to extract doc comment from trivia
83+
func extractDocComment(from variable: VariableDeclSyntax) -> String? {
84+
let trivia = variable.leadingTrivia
85+
var docLines: [String] = []
86+
87+
for piece in trivia {
88+
switch piece {
89+
case .docLineComment(let text):
90+
// Remove "/// " prefix and trim
91+
let cleaned = trimWhitespace(text.trimmingPrefix("///"))
92+
// Keep empty lines to preserve DocC paragraph structure
93+
docLines.append(cleaned)
94+
case .docBlockComment(let text):
95+
// Remove "/**" and "*/" and clean up each line
96+
let lines = text
97+
.trimmingPrefix("/**")
98+
.trimmingSuffix("*/")
99+
.split(separator: "\n")
100+
.map { line -> String in
101+
let trimmed = trimWhitespace(String(line))
102+
return trimWhitespace(trimmed.trimmingPrefix("*"))
103+
}
104+
// Keep empty lines for DocC structure
105+
docLines.append(contentsOf: lines)
106+
default:
107+
break
108+
}
109+
}
110+
111+
// Join lines with proper DocC formatting (newline + indentation)
112+
// This preserves DocC callouts like "- Note:", "- Important:", etc.
113+
return docLines.isEmpty ? nil : docLines.joined(separator: "\n/// ")
114+
}
115+
116+
// Extract properties from target struct with their documentation
61117
let properties = targetStruct.memberBlock.members
62118
.compactMap { $0.decl.as(VariableDeclSyntax.self) }
63119
.filter { $0.bindings.first?.accessorBlock == nil } // Only stored properties
64-
.flatMap { variable -> [(String, TypeSyntax)] in
65-
variable.bindings.compactMap { binding in
120+
.flatMap { variable -> [PropertyInfo] in
121+
let docComment = extractDocComment(from: variable)
122+
return variable.bindings.compactMap { binding in
66123
guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self),
67124
let type = binding.typeAnnotation?.type
68125
else {
69126
return nil
70127
}
71128
// Strip backticks from identifier text to avoid double-backticking
72129
let name = stripBackticks(identifier.identifier.text)
73-
return (name, type)
130+
return PropertyInfo(name: name, type: type, doc: docComment)
74131
}
75132
}
76133

@@ -86,19 +143,19 @@ public struct SplatMacro: MemberMacro {
86143
}
87144

88145
// Build parameter list
89-
let parameters = properties.map { name, type in
146+
let parameters = properties.map { property in
90147
FunctionParameterSyntax(
91-
firstName: .identifier("`\(name)`"),
92-
type: type
148+
firstName: .identifier("`\(property.name)`"),
149+
type: property.type
93150
)
94151
}
95152

96153
// Build target struct initializer call arguments
97-
let argumentsCallArgs = properties.map { name, _ in
154+
let argumentsCallArgs = properties.map { property in
98155
LabeledExprSyntax(
99-
label: .identifier("`\(name)`"),
156+
label: .identifier("`\(property.name)`"),
100157
colon: .colonToken(trailingTrivia: .space),
101-
expression: DeclReferenceExprSyntax(baseName: .identifier("`\(name)`"))
158+
expression: DeclReferenceExprSyntax(baseName: .identifier("`\(property.name)`"))
102159
)
103160
}
104161

@@ -122,20 +179,159 @@ public struct SplatMacro: MemberMacro {
122179
// Build the try keyword if needed
123180
let tryKeyword = hasThrowingInit ? "try " : ""
124181

125-
// Generate parameter documentation
126-
let parameterDocs = properties.map { name, type in
127-
"/// - \(name): \(type.trimmed)"
182+
// Extract full documentation from Arguments initializer
183+
let (argumentsInitDoc, argumentsParamDocs): (String?, [String: String]) = {
184+
let inits = targetStruct.memberBlock.members
185+
.compactMap { $0.decl.as(InitializerDeclSyntax.self) }
186+
187+
guard let firstInit = inits.first else { return (nil, [:]) }
188+
189+
let trivia = firstInit.leadingTrivia
190+
var docLines: [String] = []
191+
192+
for piece in trivia {
193+
switch piece {
194+
case .docLineComment(let text):
195+
let cleaned = trimWhitespace(text.trimmingPrefix("///"))
196+
// Keep empty lines to preserve DocC paragraph structure
197+
docLines.append(cleaned)
198+
case .docBlockComment(let text):
199+
let lines = text
200+
.trimmingPrefix("/**")
201+
.trimmingSuffix("*/")
202+
.split(separator: "\n")
203+
.map { line -> String in
204+
let trimmed = trimWhitespace(String(line))
205+
return trimWhitespace(trimmed.trimmingPrefix("*"))
206+
}
207+
// Keep empty lines for DocC structure
208+
docLines.append(contentsOf: lines)
209+
default:
210+
break
211+
}
212+
}
213+
214+
// Find where "- Parameters:" starts
215+
guard let paramIndex = docLines.firstIndex(where: { $0.hasPrefix("- Parameters:") }) else {
216+
return (docLines.isEmpty ? nil : docLines.joined(separator: "\n/// "), [:])
217+
}
218+
219+
// Extract summary/discussion (everything before - Parameters:)
220+
let summaryLines = Array(docLines[..<paramIndex])
221+
let summary = summaryLines.isEmpty ? nil : summaryLines.joined(separator: "\n/// ")
222+
223+
// Extract parameter docs
224+
var paramDocs: [String: String] = [:]
225+
var currentParam: String?
226+
var currentParamLines: [String] = []
227+
228+
for line in docLines[(paramIndex + 1)...] {
229+
if line.hasPrefix("- ") {
230+
// Save previous parameter if exists
231+
if let param = currentParam {
232+
paramDocs[param] = currentParamLines.joined(separator: "\n/// ")
233+
}
234+
// Start new parameter
235+
let parts = line.dropFirst(2).split(separator: ":", maxSplits: 1)
236+
if parts.count == 2 {
237+
currentParam = trimWhitespace(String(parts[0]))
238+
currentParamLines = [trimWhitespace(String(parts[1]))]
239+
}
240+
} else {
241+
// Continuation of current parameter
242+
currentParamLines.append(line)
243+
}
244+
}
245+
246+
// Save last parameter
247+
if let param = currentParam {
248+
paramDocs[param] = currentParamLines.joined(separator: "\n/// ")
249+
}
250+
251+
return (summary, paramDocs)
252+
}()
253+
254+
// Generate parameter documentation using Arguments init param docs
255+
let parameterDocs = properties.map { property in
256+
if let doc = argumentsParamDocs[property.name] {
257+
// Use the parameter documentation from Arguments init
258+
return "/// - \(property.name): \(doc)"
259+
} else if let doc = property.doc {
260+
// Fall back to property documentation
261+
return "/// - \(property.name): \(doc)"
262+
} else {
263+
// Last resort: just the type
264+
return "/// - \(property.name): \(property.type.trimmed)"
265+
}
128266
}.joined(separator: "\n")
129267

130-
// Generate throws documentation if needed
131-
let throwsDocs = hasThrowingInit ? "\n/// - Throws: Error if initialization fails." : ""
268+
// Extract throws documentation from parent struct's init (the one that takes Arguments)
269+
let throwsDocs: String = {
270+
if !hasThrowingInit {
271+
return ""
272+
}
132273

133-
// Generate the convenience initializer with comprehensive DocC comments
134-
let initializer: DeclSyntax = """
135-
/// Initializer accepting ``\(raw: structName)`` properties as individual parameters.
274+
// Find the parent struct's init that takes the Arguments struct
275+
let parentInits = declaration.memberBlock.members
276+
.compactMap { $0.decl.as(InitializerDeclSyntax.self) }
277+
278+
for parentInit in parentInits {
279+
// Check if it throws
280+
guard parentInit.signature.effectSpecifiers?.throwsClause != nil else {
281+
continue
282+
}
283+
284+
// Extract its documentation
285+
let trivia = parentInit.leadingTrivia
286+
var docLines: [String] = []
287+
288+
for piece in trivia {
289+
switch piece {
290+
case .docLineComment(let text):
291+
let cleaned = trimWhitespace(text.trimmingPrefix("///"))
292+
docLines.append(cleaned)
293+
case .docBlockComment(let text):
294+
let lines = text
295+
.trimmingPrefix("/**")
296+
.trimmingSuffix("*/")
297+
.split(separator: "\n")
298+
.map { line -> String in
299+
let trimmed = trimWhitespace(String(line))
300+
return trimWhitespace(trimmed.trimmingPrefix("*"))
301+
}
302+
docLines.append(contentsOf: lines)
303+
default:
304+
break
305+
}
306+
}
307+
308+
// Find "- Throws:" line
309+
for line in docLines where line.hasPrefix("- Throws:") {
310+
let throwsText = trimWhitespace(String(line.dropFirst("- Throws:".count)))
311+
return "\n/// - Throws: \(throwsText)"
312+
}
313+
}
314+
315+
// Fallback if no throws documentation found
316+
return "\n/// - Throws: Error if initialization fails."
317+
}()
318+
319+
// Use Arguments init doc if available, otherwise use generic description
320+
let summaryDoc: String
321+
if let initDoc = argumentsInitDoc {
322+
summaryDoc = "/// \(initDoc)"
323+
} else {
324+
summaryDoc = """
325+
/// Initializer accepting ``\(structName)`` properties as individual parameters.
136326
///
137327
/// This initializer provides direct parameter access without explicitly creating
138-
/// a ``\(raw: structName)`` instance.
328+
/// a ``\(structName)`` instance.
329+
"""
330+
}
331+
332+
// Generate the convenience initializer with comprehensive DocC comments
333+
let initializer: DeclSyntax = """
334+
\(raw: summaryDoc)
139335
///
140336
/// - Parameters:
141337
\(raw: parameterDocs)\(raw: throwsDocs)
@@ -165,3 +361,16 @@ enum SplatError: Error, CustomStringConvertible {
165361
}
166362
}
167363
}
364+
365+
// String helpers for doc comment extraction
366+
extension String {
367+
func trimmingPrefix(_ prefix: String) -> String {
368+
guard hasPrefix(prefix) else { return self }
369+
return String(dropFirst(prefix.count))
370+
}
371+
372+
func trimmingSuffix(_ suffix: String) -> String {
373+
guard hasSuffix(suffix) else { return self }
374+
return String(dropLast(suffix.count))
375+
}
376+
}

0 commit comments

Comments
 (0)