Skip to content

Commit 0185d06

Browse files
committed
[Debug] Add DebugDescription macro
1 parent b7d8a9b commit 0185d06

14 files changed

+586
-0
lines changed

lib/Macros/Sources/SwiftMacros/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
add_swift_macro_library(SwiftMacros
1414
OptionSetMacro.swift
15+
DebugDescriptionMacro.swift
1516
SWIFT_DEPENDENCIES
1617
SwiftDiagnostics
1718
SwiftSyntax
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import SwiftSyntax
2+
import SwiftSyntaxMacros
3+
import SwiftDiagnostics
4+
5+
public struct DebugDescriptionMacro {}
6+
7+
extension DebugDescriptionMacro: PeerMacro {
8+
public static func expansion<Decl, Context>(
9+
of node: AttributeSyntax,
10+
providingPeersOf declaration: Decl,
11+
in context: Context
12+
)
13+
throws -> [DeclSyntax]
14+
where Decl: DeclSyntaxProtocol, Context: MacroExpansionContext
15+
{
16+
// Determine the module name by doing a bit of parsing on fileID.
17+
guard let fileID = context.location(of: declaration)?.file,
18+
let moduleName = extractModuleName(from: fileID) else {
19+
assertionFailure("could not determine module name from fileID")
20+
return []
21+
}
22+
23+
guard let typeName = identifier(of: declaration)?.text else {
24+
let message: ErrorMessage = "must be attached to a struct/class/enum/extension"
25+
context.diagnose(node: node, message: message)
26+
return []
27+
}
28+
29+
// Create a lookup for finding properties by name.
30+
var properties: [String: PatternBindingSyntax] = [:]
31+
for member in memberBlock(declaration)?.members ?? [] {
32+
for binding in member.decl.as(VariableDeclSyntax.self)?.bindings ?? [] {
33+
if let name = bindingName(binding) {
34+
properties[name] = binding
35+
}
36+
}
37+
}
38+
39+
// Use `debugDescription` if available, otherwise fallback to `description`.
40+
guard let descriptionProperty = properties["debugDescription"] ?? properties["description"] else {
41+
let message: ErrorMessage = "debugDescription or description must be defined within \(syntaxKeyword(for: declaration))"
42+
context.diagnose(node: declaration, message: message)
43+
return []
44+
}
45+
46+
// Validate the body of the description function.
47+
// 1. The body must have a single item
48+
// 2. The single item must be a string literal
49+
// 3. Later on, the interpolation in the string literal will be validated.
50+
guard let codeBlock = descriptionProperty.accessorBlock?.accessors.as(CodeBlockItemListSyntax.self),
51+
let descriptionString = codeBlock.single?.item.as(StringLiteralExprSyntax.self) else {
52+
let message: ErrorMessage = "\(bindingName(descriptionProperty)!) must consist of a single string literal"
53+
context.diagnose(node: descriptionProperty, message: message)
54+
return []
55+
}
56+
57+
// Precompute which properties are known to be computed. Used for producing diagnostics.
58+
var computedProperties: Set<String> = []
59+
for property in properties.values {
60+
if isComputedProperty(property), let name = bindingName(property) {
61+
computedProperties.insert(name)
62+
}
63+
}
64+
65+
// Iterate the string's segments, and convert property expressions into LLDB variable references.
66+
var summarySegments: [String] = []
67+
for segment in descriptionString.segments {
68+
switch segment {
69+
case let .stringSegment(segment):
70+
summarySegments.append(segment.content.text)
71+
case let .expressionSegment(segment):
72+
guard let labeledExpr = segment.expressions.single, labeledExpr.label == nil else {
73+
// This catches `appendInterpolation` overrides.
74+
let message: ErrorMessage = "unsupported custom string interpolation expression"
75+
context.diagnose(node: segment, message: message)
76+
return []
77+
}
78+
79+
let expr = labeledExpr.expression
80+
81+
var propertyChain: [String] = []
82+
if let declRef = expr.as(DeclReferenceExprSyntax.self) {
83+
// A reference to a single property on self.
84+
propertyChain.append(declRef.baseName.text)
85+
} else if let memberAccess = expr.as(MemberAccessExprSyntax.self) {
86+
// A chain of properties, possibly starting with an explicit self.
87+
enumerateMembers(memberAccess) { declRef in
88+
propertyChain.append(declRef.baseName.text)
89+
}
90+
} else {
91+
// The expression was neither a DeclReference nor a MemberAccess.
92+
let message: ErrorMessage = "unsupported expression; stored properties only"
93+
context.diagnose(node: expr, message: message)
94+
return []
95+
}
96+
97+
// Explicit self are removed before use by LLDB.
98+
if propertyChain[0] == "self" {
99+
propertyChain.removeFirst()
100+
}
101+
102+
// Check that the root property is not a computed property on `self`.
103+
// Ideally, all properties would be checked, but a macro expansion can
104+
// at best only check the properties of the type it's attached to.
105+
let rootProperty = propertyChain[0]
106+
guard !computedProperties.contains(rootProperty) else {
107+
let message: ErrorMessage = "cannot reference computed properties"
108+
context.diagnose(node: expr, message: message)
109+
return []
110+
}
111+
112+
let propertyPath = propertyChain.joined(separator: ".")
113+
summarySegments.append("${var.\(propertyPath)}")
114+
@unknown default:
115+
return []
116+
}
117+
}
118+
119+
let summaryString = summarySegments.joined()
120+
121+
var typeIdentifier: String
122+
if let typeParameters = genericParameters(declaration), typeParameters.count > 0 {
123+
let typePatterns = Array(repeating: ".+", count: typeParameters.count).joined(separator: ",")
124+
// A regex matching that matches the generic type.
125+
typeIdentifier = "^\(moduleName)\\.\(typeName)<\(typePatterns)>"
126+
} else if declaration.is(ExtensionDeclSyntax.self) {
127+
// When attached to an extension, the type may or may not be a generic type.
128+
// This regular expression handles both cases.
129+
typeIdentifier = "^\(moduleName)\\.\(typeName)(<.+>)?$"
130+
} else {
131+
typeIdentifier = "\(moduleName).\(typeName)"
132+
}
133+
134+
// Serialize the type summary into a global record, in a custom section, for LLDB to load.
135+
let decl: DeclSyntax = """
136+
#if os(Linux)
137+
@_section(".lldbsummaries")
138+
#elseif os(Windows)
139+
@_section(".lldbsummaries")
140+
#else
141+
@_section("__DATA_CONST,__lldbsummaries")
142+
#endif
143+
@_used
144+
let \(raw: typeName)_lldb_summary = (
145+
\(raw: encodeTypeSummaryRecord(typeIdentifier, summaryString))
146+
)
147+
"""
148+
149+
return [decl]
150+
}
151+
}
152+
153+
// MARK: - Diagnostics
154+
155+
struct ErrorMessage: DiagnosticMessage, ExpressibleByStringInterpolation {
156+
init(stringLiteral value: String) {
157+
self.message = value
158+
}
159+
var message: String
160+
var diagnosticID: MessageID { .init(domain: "DebugDescription", id: "DebugDescription")}
161+
var severity: DiagnosticSeverity { .error }
162+
}
163+
164+
extension MacroExpansionContext {
165+
fileprivate func diagnose(node: some SyntaxProtocol, message: any DiagnosticMessage) {
166+
diagnose(Diagnostic(node: node, message: message))
167+
}
168+
}
169+
170+
// MARK: - AST Helpers
171+
172+
private func extractModuleName(from fileID: ExprSyntax) -> String? {
173+
if let fileID = fileID.as(StringLiteralExprSyntax.self)?.representedLiteralValue,
174+
let firstSlash = fileID.firstIndex(of: "/") {
175+
return String(fileID.prefix(upTo: firstSlash))
176+
}
177+
return nil
178+
}
179+
180+
private func identifier<Decl: DeclSyntaxProtocol>(of decl: Decl) -> TokenSyntax? {
181+
decl.as(StructDeclSyntax.self)?.name ??
182+
decl.as(ClassDeclSyntax.self)?.name ??
183+
decl.as(EnumDeclSyntax.self)?.name ??
184+
decl.as(ExtensionDeclSyntax.self)?.extendedType.as(IdentifierTypeSyntax.self)?.name
185+
}
186+
187+
private func memberBlock<Decl: DeclSyntaxProtocol>(_ decl: Decl) -> MemberBlockSyntax? {
188+
decl.as(StructDeclSyntax.self)?.memberBlock ??
189+
decl.as(ClassDeclSyntax.self)?.memberBlock ??
190+
decl.as(EnumDeclSyntax.self)?.memberBlock ??
191+
decl.as(ExtensionDeclSyntax.self)?.memberBlock
192+
}
193+
194+
private func syntaxKeyword<Decl: DeclSyntaxProtocol>(for decl: Decl) -> String {
195+
if decl.is(StructDeclSyntax.self) { return "struct" }
196+
if decl.is(ClassDeclSyntax.self) { return "class" }
197+
if decl.is(EnumDeclSyntax.self) { return "enum" }
198+
if decl.is(ExtensionDeclSyntax.self) { return "extension" }
199+
assertionFailure("expected struct/class/enum/extension")
200+
return "declaration"
201+
}
202+
203+
private func genericParameters<Decl: DeclSyntaxProtocol>(_ decl: Decl) -> GenericParameterListSyntax? {
204+
decl.as(StructDeclSyntax.self)?.genericParameterClause?.parameters ??
205+
decl.as(ClassDeclSyntax.self)?.genericParameterClause?.parameters ??
206+
decl.as(EnumDeclSyntax.self)?.genericParameterClause?.parameters
207+
}
208+
209+
private func bindingName(_ binding: PatternBindingSyntax) -> String? {
210+
binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text
211+
}
212+
213+
private func isComputedProperty(_ binding: PatternBindingSyntax) -> Bool {
214+
guard let accessors = binding.accessorBlock?.accessors else {
215+
// No accessor block, not computed.
216+
return false
217+
}
218+
219+
switch accessors {
220+
case .accessors(let accessors):
221+
return accessors.contains { $0.accessorSpecifier.tokenKind == .keyword(.get) }
222+
case .getter:
223+
return true
224+
@unknown default:
225+
return false
226+
}
227+
}
228+
229+
// Enumerate a MemberAccess expression to produce DeclReferences in left to right order.
230+
// `a.b.c` will result in callbacks for each DeclReference `a`, `b`, and then `c`.
231+
private func enumerateMembers(_ memberAccess: MemberAccessExprSyntax, _ action: (DeclReferenceExprSyntax) -> Void) {
232+
if let baseMembers = memberAccess.base?.as(MemberAccessExprSyntax.self) {
233+
enumerateMembers(baseMembers, action)
234+
} else if let baseDecl = memberAccess.base?.as(DeclReferenceExprSyntax.self) {
235+
action(baseDecl)
236+
}
237+
action(memberAccess.declName)
238+
}
239+
240+
// MARK: - Encoding
241+
242+
private func encodeTypeSummaryRecord(_ typeIdentifier: String, _ summaryString: String) -> String {
243+
let encodedType = encodeString(typeIdentifier)
244+
let encodedSummary = encodeString(summaryString)
245+
let recordSize = UInt(encodedType.count + encodedSummary.count)
246+
return """
247+
/* version */ 1 as UInt8,
248+
/* record size */ \(literalBytes(encodeULEB(recordSize))),
249+
/* "\(typeIdentifier)" */ \(literalBytes(encodedType)),
250+
/* "\(summaryString)" */ \(literalBytes(encodedSummary))
251+
"""
252+
}
253+
254+
private func literalBytes(_ bytes: [UInt8]) -> String {
255+
bytes.map({ "\($0) as UInt8" }).joined(separator: ", ")
256+
}
257+
258+
private func encodeString(_ string: String) -> [UInt8] {
259+
let size = UInt(string.utf8.count) + 1 // including null terminator
260+
var bytes: [UInt8] = []
261+
bytes.append(contentsOf: encodeULEB(size))
262+
bytes.append(contentsOf: Array(string.utf8))
263+
bytes.append(0) // null terminator
264+
return bytes
265+
}
266+
267+
private func encodeULEB(_ value: UInt) -> [UInt8] {
268+
var bytes: [UInt8] = []
269+
var buffer = value
270+
while buffer > 0 {
271+
var byte = UInt8(buffer & 0b01111111)
272+
buffer >>= 7
273+
if buffer > 0 {
274+
byte &= 0b1000000
275+
}
276+
bytes.append(byte)
277+
}
278+
return bytes
279+
}
280+
281+
// MARK: - Extensions
282+
283+
extension Collection {
284+
fileprivate var single: Element? {
285+
count == 1 ? first : nil
286+
}
287+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// REQUIRES: swift_swift_parser
2+
3+
// RUN: %empty-directory(%t)
4+
// RUN: %target-swift-frontend %s -swift-version 5 -module-name main -typecheck -enable-experimental-feature SymbolLinkageMarkers -plugin-path %swift-plugin-dir -dump-macro-expansions > %t/expansions-dump.txt 2>&1
5+
// RUN: %FileCheck %s < %t/expansions-dump.txt
6+
7+
@attached(peer, names: suffixed(_lldb_summary))
8+
public macro _DebugDescription() =
9+
#externalMacro(module: "SwiftMacros", type: "DebugDescriptionMacro")
10+
11+
@_DebugDescription
12+
struct MyStruct1: CustomStringConvertible {
13+
var description: String { "thirty" }
14+
}
15+
// CHECK: let MyStruct1_lldb_summary = (
16+
// CHECK: /* version */ 1 as UInt8,
17+
// CHECK: /* record size */ 24 as UInt8,
18+
// CHECK: /* "main.MyStruct1" */ 15 as UInt8, 109 as UInt8, 97 as UInt8, 105 as UInt8, 110 as UInt8, 46 as UInt8, 77 as UInt8, 121 as UInt8, 83 as UInt8, 116 as UInt8, 114 as UInt8, 117 as UInt8, 99 as UInt8, 116 as UInt8, 49 as UInt8, 0 as UInt8,
19+
// CHECK: /* "thirty" */ 7 as UInt8, 116 as UInt8, 104 as UInt8, 105 as UInt8, 114 as UInt8, 116 as UInt8, 121 as UInt8, 0 as UInt8
20+
// CHECK: )
21+
22+
@_DebugDescription
23+
struct MyStruct2: CustomDebugStringConvertible {
24+
var debugDescription: String { "thirty" }
25+
}
26+
// CHECK: let MyStruct2_lldb_summary = (
27+
// CHECK: /* version */ 1 as UInt8,
28+
// CHECK: /* record size */ 24 as UInt8,
29+
// CHECK: /* "main.MyStruct2" */ 15 as UInt8, 109 as UInt8, 97 as UInt8, 105 as UInt8, 110 as UInt8, 46 as UInt8, 77 as UInt8, 121 as UInt8, 83 as UInt8, 116 as UInt8, 114 as UInt8, 117 as UInt8, 99 as UInt8, 116 as UInt8, 50 as UInt8, 0 as UInt8,
30+
// CHECK: /* "thirty" */ 7 as UInt8, 116 as UInt8, 104 as UInt8, 105 as UInt8, 114 as UInt8, 116 as UInt8, 121 as UInt8, 0 as UInt8
31+
// CHECK: )
32+
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// REQUIRES: swift_swift_parser
2+
3+
// RUN: %empty-directory(%t)
4+
// RUN: %target-swift-frontend %s -swift-version 5 -module-name main -typecheck -verify -plugin-path %swift-plugin-dir
5+
6+
@attached(peer, names: suffixed(_lldb_summary))
7+
public macro _DebugDescription() =
8+
#externalMacro(module: "SwiftMacros", type: "DebugDescriptionMacro")
9+
10+
@_DebugDescription
11+
struct MyStruct {
12+
var flag: Bool
13+
14+
// expected-error @+1 {{debugDescription must consist of a single string literal}}
15+
var debugDescription: String {
16+
flag ? "yes" : "no"
17+
}
18+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// REQUIRES: swift_swift_parser
2+
3+
// RUN: %empty-directory(%t)
4+
// RUN: %target-swift-frontend %s -swift-version 5 -module-name main -typecheck -verify -plugin-path %swift-plugin-dir
5+
6+
@attached(peer, names: suffixed(_lldb_summary))
7+
public macro _DebugDescription() =
8+
#externalMacro(module: "SwiftMacros", type: "DebugDescriptionMacro")
9+
10+
@_DebugDescription
11+
struct MyStruct {
12+
var name: String { "thirty" }
13+
14+
// expected-error @+1 {{cannot reference computed properties}}
15+
var debugDescription: String { "name: \(name)" }
16+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// REQUIRES: swift_swift_parser
2+
3+
// RUN: %empty-directory(%t)
4+
// RUN: %target-swift-frontend %s -swift-version 5 -module-name main -typecheck -verify -plugin-path %swift-plugin-dir
5+
6+
@attached(peer, names: suffixed(_lldb_summary))
7+
public macro _DebugDescription() =
8+
#externalMacro(module: "SwiftMacros", type: "DebugDescriptionMacro")
9+
10+
extension DefaultStringInterpolation {
11+
fileprivate func appendInterpolation(custom: Int) {}
12+
fileprivate func appendInterpolation<A, B>(_ a: A, _ b: B) {}
13+
}
14+
15+
@_DebugDescription
16+
struct MyStruct1 {
17+
// expected-error @+1 {{unsupported custom string interpolation expression}}
18+
var debugDescription: String { "\(custom: 30)" }
19+
}
20+
21+
@_DebugDescription
22+
struct MyStruct2 {
23+
// expected-error @+1 {{unsupported custom string interpolation expression}}
24+
var debugDescription: String { "\(30, true)" }
25+
}

0 commit comments

Comments
 (0)