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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy