Skip to content

Commit da992cb

Browse files
committed
Pack expression IDs as single integers or integer arrays (rare) instead of strings
1 parent 9a3b6ce commit da992cb

File tree

3 files changed

+138
-58
lines changed

3 files changed

+138
-58
lines changed

Sources/Testing/SourceAttribution/ExpressionID.swift

Lines changed: 94 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,68 +11,137 @@
1111
/// A type providing unique identifiers for expressions captured during
1212
/// expansion of the `#expect()` and `#require()` macros.
1313
///
14-
/// In the future, this type may use [`StaticBigInt`](https://developer.apple.com/documentation/swift/staticbigint)
15-
/// as its source representation rather than a string literal.
14+
/// This type tries to optimize for expressions in shallow syntax trees whose
15+
/// unique identifiers require 64 bits or fewer. Wider unique identifiers are
16+
/// stored as arrays of 64-bit words. In the future, this type may use
17+
/// [`StaticBigInt`](https://developer.apple.com/documentation/swift/staticbigint)
18+
/// to represent expression identifiers instead.
1619
///
1720
/// - Warning: This type is used to implement the `#expect()` and `#require()`
1821
/// macros. Do not use it directly.
1922
public struct __ExpressionID: Sendable {
2023
/// The ID of the root node in an expression graph.
2124
static var root: Self {
22-
""
25+
Self(_elements: .none)
2326
}
2427

25-
/// The string produced at compile time that encodes the unique identifier of
26-
/// the represented expression.
27-
var stringValue: String
28+
/// An enumeration that attempts to efficiently store the key path elements
29+
/// corresponding to an expression ID.
30+
fileprivate enum Elements: Sendable {
31+
/// This ID does not use any words.
32+
///
33+
/// This case represents the root node in a syntax tree. An instance of
34+
/// `__ExpressionID` storing this case is implicitly equal to `.root`.
35+
case none
2836

29-
/// The number of bits in a nybble.
30-
private static var _bitsPerNybble: Int { 4 }
37+
/// This ID packs its corresponding key path value into a single word whose
38+
/// value is not `0`.
39+
case packed(_ word: UInt64)
40+
41+
/// This ID contains key path elements that do not fit in a 64-bit integer,
42+
/// so they are not packed and map directly to the represented key path.
43+
indirect case keyPath(_ keyPath: [UInt32])
44+
}
45+
46+
/// The elements of this identifier.
47+
private var _elements: Elements
3148

3249
/// A representation of this instance suitable for use as a key path in an
3350
/// instance of `Graph` where the key type is `UInt32`.
3451
///
3552
/// The values in this collection, being swift-syntax node IDs, are never more
3653
/// than 32 bits wide.
3754
var keyPath: some RandomAccessCollection<UInt32> {
38-
let nybbles = stringValue
39-
.reversed().lazy
40-
.compactMap { UInt8(String($0), radix: 16) }
41-
42-
return nybbles
43-
.enumerated()
44-
.flatMap { i, nybble in
45-
let nybbleOffset = i * Self._bitsPerNybble
46-
return (0 ..< Self._bitsPerNybble).lazy
47-
.filter { (nybble & (1 << $0)) != 0 }
48-
.map { UInt32(nybbleOffset + $0) }
55+
// Helper function to unpack a sequence of words into bit indices for use as
56+
// a Graph's key path.
57+
func makeKeyPath(from words: some RandomAccessCollection<UInt64>) -> [UInt32] {
58+
// Assume approximately 1/4 of the bits are populated. We can always tweak
59+
// this guesstimate after gathering more real-world data.
60+
var result = [UInt32]()
61+
result.reserveCapacity((words.count * UInt64.bitWidth) / 4)
62+
63+
for (bitOffset, word) in words.enumerated() {
64+
var word = word
65+
while word != 0 {
66+
let bit = word.trailingZeroBitCount
67+
result.append(UInt32(bit + bitOffset))
68+
word = word & (word &- 1) // Mask off the bit we just counted.
69+
}
4970
}
71+
72+
return result
73+
}
74+
75+
switch _elements {
76+
case .none:
77+
return []
78+
case let .packed(word):
79+
// Assume approximately 1/4 of the bits are populated. We can always tweak
80+
// this guesstimate after gathering more real-world data.
81+
var result = [UInt32]()
82+
result.reserveCapacity(UInt64.bitWidth / 4)
83+
84+
var word = word
85+
while word != 0 {
86+
let bit = word.trailingZeroBitCount
87+
result.append(UInt32(bit))
88+
word = word & (word &- 1) // Mask off the bit we just counted.
89+
}
90+
91+
return result
92+
case let .keyPath(keyPath):
93+
return keyPath
94+
}
5095
}
5196
}
5297

5398
// MARK: - Equatable, Hashable
5499

55100
extension __ExpressionID: Equatable, Hashable {}
101+
extension __ExpressionID.Elements: Equatable, Hashable {}
56102

57103
#if DEBUG
58104
// MARK: - CustomStringConvertible, CustomDebugStringConvertible
59105

60106
extension __ExpressionID: CustomStringConvertible, CustomDebugStringConvertible {
107+
/// The number of bits in a nybble.
108+
private static var _bitsPerNybble: Int { 4 }
109+
110+
/// The number of nybbles in a word.
111+
private static var _nybblesPerWord: Int { UInt64.bitWidth / _bitsPerNybble }
112+
61113
public var description: String {
62-
stringValue
114+
switch _elements {
115+
case .none:
116+
return "0"
117+
case let .packed(word):
118+
return String(word, radix: 16)
119+
case let .keyPath(keyPath):
120+
return keyPath.lazy
121+
.map { String($0, radix: 16) }
122+
.joined(separator: ",")
123+
}
63124
}
64125

65126
public var debugDescription: String {
66-
#""\#(stringValue)" → \#(Array(keyPath))"#
127+
#""\#(description)" → \#(Array(keyPath))"#
67128
}
68129
}
69130
#endif
70131

71-
// MARK: - ExpressibleByStringLiteral
132+
// MARK: - ExpressibleByIntegerLiteral
133+
134+
extension __ExpressionID: ExpressibleByIntegerLiteral {
135+
public init(integerLiteral: UInt64) {
136+
if integerLiteral == 0 {
137+
self.init(_elements: .none)
138+
} else {
139+
self.init(_elements: .packed(integerLiteral))
140+
}
141+
}
72142

73-
extension __ExpressionID: ExpressibleByStringLiteral {
74-
public init(stringLiteral: String) {
75-
stringValue = stringLiteral
143+
public init(_ keyPath: UInt32...) {
144+
self.init(_elements: .keyPath(keyPath))
76145
}
77146
}
78147

Sources/TestingMacros/ConditionMacro.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,12 +201,12 @@ extension ConditionMacro {
201201

202202
// Sort the rewritten nodes. This isn't strictly necessary for
203203
// correctness but it does make the produced code more consistent.
204-
let sortedRewrittenNodes = rewrittenNodes.sorted { $0.id < $1.id }
205-
let sourceCodeNodeIDs = sortedRewrittenNodes.compactMap { $0.expressionID(rootedAt: originalArgumentExpr) }
206-
let sourceCodeExprs = sortedRewrittenNodes.map { StringLiteralExprSyntax(content: $0.trimmedDescription) }
207204
let sourceCodeExpr = DictionaryExprSyntax {
208-
for (nodeID, sourceCodeExpr) in zip(sourceCodeNodeIDs, sourceCodeExprs) {
209-
DictionaryElementSyntax(key: nodeID, value: sourceCodeExpr)
205+
for node in (rewrittenNodes.sorted { $0.id < $1.id }) {
206+
DictionaryElementSyntax(
207+
key: node.expressionID(rootedAt: originalArgumentExpr),
208+
value: StringLiteralExprSyntax(content: node.trimmedDescription)
209+
)
210210
}
211211
}
212212
checkArguments.append(Argument(label: "sourceCode", expression: sourceCodeExpr))

Sources/TestingMacros/Support/Additions/SyntaxProtocolAdditions.swift

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
//
1010

1111
import SwiftSyntax
12+
import SwiftSyntaxBuilder
1213

1314
extension SyntaxProtocol {
1415
/// Get an expression representing the unique ID of this syntax node as well
@@ -26,21 +27,21 @@ extension SyntaxProtocol {
2627
// rewritten.
2728
var nodeIDChain = sequence(first: Syntax(self), next: \.parent)
2829
.map { $0.id.indexInTree.toOpaque() }
29-
30+
3031
#if DEBUG
3132
assert(nodeIDChain.sorted() == nodeIDChain.reversed(), "Child node had lower ID than parent node in sequence \(nodeIDChain). Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new")
3233
for id in nodeIDChain {
3334
assert(id <= UInt32.max, "Node ID \(id) was not a 32-bit integer. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new")
3435
}
3536
#endif
36-
37+
38+
#if DEBUG
3739
// The highest ID in the chain determines the number of bits needed, and the
3840
// ID of this node will always be the highest (per the assertion above.)
39-
let maxID = id.indexInTree.toOpaque()
40-
#if DEBUG
41-
assert(nodeIDChain.contains(maxID), "ID \(maxID) of syntax node '\(self.trimmed)' was not found in its node ID chain \(nodeIDChain). Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new")
41+
let expectedMaxID = id.indexInTree.toOpaque()
42+
assert(nodeIDChain.contains(expectedMaxID), "ID \(expectedMaxID) of syntax node '\(self.trimmed)' was not found in its node ID chain \(nodeIDChain). Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new")
4243
#endif
43-
44+
4445
// Adjust all node IDs downards by the effective root node's ID, then remove
4546
// the effective root node and its ancestors. This allows us to use lower
4647
// bit ranges than we would if we always included those nodes.
@@ -51,28 +52,38 @@ extension SyntaxProtocol {
5152
}
5253
}
5354

54-
// Convert the node IDs in the chain to bits in a bit mask.
55-
let bitsPerWord = UInt64(UInt64.bitWidth)
56-
var words = [UInt64](
57-
repeating: 0,
58-
count: Int(((maxID + 1) + (bitsPerWord - 1)) / bitsPerWord)
59-
)
60-
for id in nodeIDChain {
61-
let (word, bit) = id.quotientAndRemainder(dividingBy: bitsPerWord)
62-
words[Int(word)] |= (1 << bit)
63-
}
64-
65-
// Convert the bits to a hexadecimal string.
66-
let bitsPerNybble = 4
67-
let nybblesPerWord = UInt64.bitWidth / bitsPerNybble
68-
var id: String = words.map { word in
69-
let result = String(word, radix: 16)
70-
return String(repeating: "0", count: nybblesPerWord - result.count) + result
71-
}.joined()
72-
73-
// Drop any redundant leading zeroes from the string literal.
74-
id = String(id.drop { $0 == "0" })
55+
let maxID = nodeIDChain.max() ?? 0
56+
if maxID < UInt64.bitWidth {
57+
// Pack all the node IDs into a single integer value.
58+
var word = UInt64(0)
59+
for id in nodeIDChain {
60+
word |= (1 << id)
61+
}
62+
let hexWord = "0x\(String(word, radix: 16))"
63+
return ExprSyntax(IntegerLiteralExprSyntax(literal: .integerLiteral(hexWord)))
7564

76-
return ExprSyntax(StringLiteralExprSyntax(content: id))
65+
} else {
66+
// Some ID exceeds what we can fit in a single literal, so just produce an
67+
// array of node IDs instead.
68+
let idExprs = nodeIDChain.map { id in
69+
IntegerLiteralExprSyntax(literal: .integerLiteral("\(id)"))
70+
}
71+
return ExprSyntax(
72+
FunctionCallExprSyntax(
73+
calledExpression: TypeExprSyntax(
74+
type: MemberTypeSyntax(
75+
baseType: IdentifierTypeSyntax(name: .identifier("Testing")),
76+
name: .identifier("__ExpressionID")
77+
)
78+
),
79+
leftParen: .leftParenToken(),
80+
rightParen: .rightParenToken()
81+
) {
82+
for idExpr in idExprs {
83+
LabeledExprSyntax(expression: idExpr)
84+
}
85+
}
86+
)
87+
}
7788
}
7889
}

0 commit comments

Comments
 (0)