Skip to content

Commit 4005676

Browse files
authored
Add support for raw identifiers. (#887)
This PR adds support for the raw identifiers feature introduced with [SE-0451](https://forums.swift.org/t/accepted-with-revision-se-0451-raw-identifiers/76387). Resolves #842. ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated.
1 parent 89ff719 commit 4005676

File tree

11 files changed

+291
-31
lines changed

11 files changed

+291
-31
lines changed

Sources/Testing/Parameterization/TypeInfo.swift

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public struct TypeInfo: Sendable {
6969
/// - mangled: The mangled name of the type, if available.
7070
init(fullyQualifiedName: String, unqualifiedName: String, mangledName: String?) {
7171
self.init(
72-
fullyQualifiedNameComponents: fullyQualifiedName.split(separator: ".").map(String.init),
72+
fullyQualifiedNameComponents: Self.fullyQualifiedNameComponents(ofTypeWithName: fullyQualifiedName),
7373
unqualifiedName: unqualifiedName,
7474
mangledName: mangledName
7575
)
@@ -95,10 +95,85 @@ public struct TypeInfo: Sendable {
9595

9696
// MARK: - Name
9797

98+
/// Split a string with a separator while respecting raw identifiers and their
99+
/// enclosing backtick characters.
100+
///
101+
/// - Parameters:
102+
/// - string: The string to split.
103+
/// - separator: The character that separates components of `string`.
104+
/// - maxSplits: The maximum number of splits to perform on `string`. The
105+
/// resulting array contains up to `maxSplits + 1` elements.
106+
///
107+
/// - Returns: An array of substrings of `string`.
108+
///
109+
/// Unlike `String.split(separator:maxSplits:omittingEmptySubsequences:)`, this
110+
/// function does not split the string on separator characters that occur
111+
/// between pairs of backtick characters. This is useful when splitting strings
112+
/// containing raw identifiers.
113+
///
114+
/// - Complexity: O(_n_), where _n_ is the length of `string`.
115+
func rawIdentifierAwareSplit<S>(_ string: S, separator: Character, maxSplits: Int = .max) -> [S.SubSequence] where S: StringProtocol {
116+
var result = [S.SubSequence]()
117+
118+
var inRawIdentifier = false
119+
var componentStartIndex = string.startIndex
120+
for i in string.indices {
121+
let c = string[i]
122+
if c == "`" {
123+
// We are either entering or exiting a raw identifier. While inside a raw
124+
// identifier, separator characters are ignored.
125+
inRawIdentifier.toggle()
126+
} else if c == separator && !inRawIdentifier {
127+
// Add everything up to this separator as the next component, then start
128+
// a new component after the separator.
129+
result.append(string[componentStartIndex ..< i])
130+
componentStartIndex = string.index(after: i)
131+
132+
if result.count == maxSplits {
133+
// We don't need to find more separators. We'll add the remainder of the
134+
// string outside the loop as the last component, then return.
135+
break
136+
}
137+
}
138+
}
139+
result.append(string[componentStartIndex...])
140+
141+
return result
142+
}
143+
98144
extension TypeInfo {
99145
/// An in-memory cache of fully-qualified type name components.
100146
private static let _fullyQualifiedNameComponentsCache = Locked<[ObjectIdentifier: [String]]>()
101147

148+
/// Split the given fully-qualified type name into its components.
149+
///
150+
/// - Parameters:
151+
/// - fullyQualifiedName: The string to split.
152+
///
153+
/// - Returns: The components of `fullyQualifiedName` as substrings thereof.
154+
static func fullyQualifiedNameComponents(ofTypeWithName fullyQualifiedName: String) -> [String] {
155+
var components = rawIdentifierAwareSplit(fullyQualifiedName, separator: ".")
156+
157+
// If a type is extended in another module and then referenced by name,
158+
// its name according to the String(reflecting:) API will be prefixed with
159+
// "(extension in MODULE_NAME):". For our purposes, we never want to
160+
// preserve that prefix.
161+
if let firstComponent = components.first, firstComponent.starts(with: "(extension in "),
162+
let moduleName = rawIdentifierAwareSplit(firstComponent, separator: ":", maxSplits: 1).last {
163+
// NOTE: even if the module name is a raw identifier, it comprises a
164+
// single identifier (no splitting required) so we don't need to process
165+
// it any further.
166+
components[0] = moduleName
167+
}
168+
169+
// If a type is private or embedded in a function, its fully qualified
170+
// name may include "(unknown context at $xxxxxxxx)" as a component. Strip
171+
// those out as they're uninteresting to us.
172+
components = components.filter { !$0.starts(with: "(unknown context at") }
173+
174+
return components.map(String.init)
175+
}
176+
102177
/// The complete name of this type, with the names of all referenced types
103178
/// fully-qualified by their module names when possible.
104179
///
@@ -121,22 +196,7 @@ extension TypeInfo {
121196
return cachedResult
122197
}
123198

124-
var result = String(reflecting: type)
125-
.split(separator: ".")
126-
.map(String.init)
127-
128-
// If a type is extended in another module and then referenced by name,
129-
// its name according to the String(reflecting:) API will be prefixed with
130-
// "(extension in MODULE_NAME):". For our purposes, we never want to
131-
// preserve that prefix.
132-
if let firstComponent = result.first, firstComponent.starts(with: "(extension in ") {
133-
result[0] = String(firstComponent.split(separator: ":", maxSplits: 1).last!)
134-
}
135-
136-
// If a type is private or embedded in a function, its fully qualified
137-
// name may include "(unknown context at $xxxxxxxx)" as a component. Strip
138-
// those out as they're uninteresting to us.
139-
result = result.filter { !$0.starts(with: "(unknown context at") }
199+
let result = Self.fullyQualifiedNameComponents(ofTypeWithName: String(reflecting: type))
140200

141201
Self._fullyQualifiedNameComponentsCache.withLock { fullyQualifiedNameComponentsCache in
142202
fullyQualifiedNameComponentsCache[ObjectIdentifier(type)] = result

Sources/Testing/SourceAttribution/SourceLocation.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public struct SourceLocation: Sendable {
4646
/// - ``moduleName``
4747
public var fileName: String {
4848
let lastSlash = fileID.lastIndex(of: "/")!
49-
return String(fileID[fileID.index(after: lastSlash)...])
49+
return String(fileID[lastSlash...].dropFirst())
5050
}
5151

5252
/// The name of the module containing the source file.
@@ -67,8 +67,7 @@ public struct SourceLocation: Sendable {
6767
/// - ``fileName``
6868
/// - [`#fileID`](https://developer.apple.com/documentation/swift/fileID())
6969
public var moduleName: String {
70-
let firstSlash = fileID.firstIndex(of: "/")!
71-
return String(fileID[..<firstSlash])
70+
rawIdentifierAwareSplit(fileID, separator: "/", maxSplits: 1).first.map(String.init)!
7271
}
7372

7473
/// The path to the source file.

Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift

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

1111
import SwiftSyntax
12+
import SwiftSyntaxBuilder
1213
import SwiftSyntaxMacros
1314

1415
extension FunctionDeclSyntax {
@@ -35,16 +36,24 @@ extension FunctionDeclSyntax {
3536

3637
/// The name of this function including parentheses, parameter labels, and
3738
/// colons.
38-
var completeName: String {
39-
var result = [name.textWithoutBackticks, "(",]
40-
41-
for parameter in signature.parameterClause.parameters {
42-
result.append(parameter.firstName.textWithoutBackticks)
43-
result.append(":")
39+
var completeName: DeclReferenceExprSyntax {
40+
func possiblyRaw(_ token: TokenSyntax) -> TokenSyntax {
41+
if let rawIdentifier = token.rawIdentifier {
42+
return .identifier("`\(rawIdentifier)`")
43+
}
44+
return .identifier(token.textWithoutBackticks)
4445
}
45-
result.append(")")
4646

47-
return result.joined()
47+
return DeclReferenceExprSyntax(
48+
baseName: possiblyRaw(name),
49+
argumentNames: DeclNameArgumentsSyntax(
50+
arguments: DeclNameArgumentListSyntax {
51+
for parameter in signature.parameterClause.parameters {
52+
DeclNameArgumentSyntax(name: possiblyRaw(parameter.firstName))
53+
}
54+
}
55+
)
56+
)
4857
}
4958

5059
/// An array of tuples representing this function's parameters.

Sources/TestingMacros/Support/Additions/TokenSyntaxAdditions.swift

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,39 @@
1111
import SwiftSyntax
1212

1313
extension TokenSyntax {
14+
/// A tuple containing the text of this instance with enclosing backticks
15+
/// removed and whether or not they were removed.
16+
private var _textWithoutBackticks: (String, backticksRemoved: Bool) {
17+
let text = text
18+
if case .identifier = tokenKind, text.first == "`" && text.last == "`" && text.count >= 2 {
19+
return (String(text.dropFirst().dropLast()), true)
20+
}
21+
22+
return (text, false)
23+
}
24+
1425
/// The text of this instance with all backticks removed.
1526
///
1627
/// - Bug: This property works around the presence of backticks in `text.`
1728
/// ([swift-syntax-#1936](https://github.com/swiftlang/swift-syntax/issues/1936))
1829
var textWithoutBackticks: String {
19-
text.filter { $0 != "`" }
30+
_textWithoutBackticks.0
31+
}
32+
33+
/// The raw identifier, not including enclosing backticks, represented by this
34+
/// token, or `nil` if it does not represent one.
35+
var rawIdentifier: String? {
36+
let (textWithoutBackticks, backticksRemoved) = _textWithoutBackticks
37+
if backticksRemoved, !textWithoutBackticks.isValidSwiftIdentifier(for: .memberAccess) {
38+
return textWithoutBackticks
39+
}
40+
41+
// TODO: remove this mock path once the toolchain fully supports raw IDs.
42+
let mockPrefix = "__raw__$"
43+
if backticksRemoved, textWithoutBackticks.starts(with: mockPrefix) {
44+
return String(textWithoutBackticks.dropFirst(mockPrefix.count))
45+
}
46+
47+
return nil
2048
}
2149
}

Sources/TestingMacros/Support/AttributeDiscovery.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ struct AttributeInfo {
100100
init(byParsing attribute: AttributeSyntax, on declaration: some SyntaxProtocol, in context: some MacroExpansionContext) {
101101
self.attribute = attribute
102102

103+
var displayNameArgument: LabeledExprListSyntax.Element?
103104
var nonDisplayNameArguments: [Argument] = []
104105
if let arguments = attribute.arguments, case let .argumentList(argumentList) = arguments {
105106
// If the first argument is an unlabelled string literal, it's the display
@@ -109,15 +110,27 @@ struct AttributeInfo {
109110
let firstArgumentHasLabel = (firstArgument.label != nil)
110111
if !firstArgumentHasLabel, let stringLiteral = firstArgument.expression.as(StringLiteralExprSyntax.self) {
111112
displayName = stringLiteral
113+
displayNameArgument = firstArgument
112114
nonDisplayNameArguments = argumentList.dropFirst().map(Argument.init)
113115
} else if !firstArgumentHasLabel, firstArgument.expression.is(NilLiteralExprSyntax.self) {
116+
displayNameArgument = firstArgument
114117
nonDisplayNameArguments = argumentList.dropFirst().map(Argument.init)
115118
} else {
116119
nonDisplayNameArguments = argumentList.map(Argument.init)
117120
}
118121
}
119122
}
120123

124+
// Disallow an explicit display name for tests and suites with raw
125+
// identifier names as it's redundant and potentially confusing.
126+
if let namedDecl = declaration.asProtocol((any NamedDeclSyntax).self),
127+
let rawIdentifier = namedDecl.name.rawIdentifier {
128+
if let displayName, let displayNameArgument {
129+
context.diagnose(.declaration(namedDecl, hasExtraneousDisplayName: displayName, fromArgument: displayNameArgument, using: attribute))
130+
}
131+
displayName = StringLiteralExprSyntax(content: rawIdentifier)
132+
}
133+
121134
// Remove leading "Self." expressions from the arguments of the attribute.
122135
// See _SelfRemover for more information. Rewriting a syntax tree discards
123136
// location information from the copy, so only invoke the rewriter if the

Sources/TestingMacros/Support/DiagnosticMessage.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,41 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
645645
)
646646
}
647647

648+
/// Create a diagnostic message stating that a declaration has two display
649+
/// names.
650+
///
651+
/// - Parameters:
652+
/// - decl: The declaration that has two display names.
653+
/// - displayNameFromAttribute: The display name provided by the `@Test` or
654+
/// `@Suite` attribute.
655+
/// - argumentContainingDisplayName: The argument node containing the node
656+
/// `displayNameFromAttribute`.
657+
/// - attribute: The `@Test` or `@Suite` attribute.
658+
///
659+
/// - Returns: A diagnostic message.
660+
static func declaration(
661+
_ decl: some NamedDeclSyntax,
662+
hasExtraneousDisplayName displayNameFromAttribute: StringLiteralExprSyntax,
663+
fromArgument argumentContainingDisplayName: LabeledExprListSyntax.Element,
664+
using attribute: AttributeSyntax
665+
) -> Self {
666+
Self(
667+
syntax: Syntax(decl),
668+
message: "Attribute \(_macroName(attribute)) specifies display name '\(displayNameFromAttribute.representedLiteralValue!)' for \(_kindString(for: decl)) with implicit display name '\(decl.name.rawIdentifier!)'",
669+
severity: .error,
670+
fixIts: [
671+
FixIt(
672+
message: MacroExpansionFixItMessage("Remove '\(displayNameFromAttribute.representedLiteralValue!)'"),
673+
changes: [.replace(oldNode: Syntax(argumentContainingDisplayName), newNode: Syntax("" as ExprSyntax))]
674+
),
675+
FixIt(
676+
message: MacroExpansionFixItMessage("Rename '\(decl.name.textWithoutBackticks)'"),
677+
changes: [.replace(oldNode: Syntax(decl.name), newNode: Syntax(EditorPlaceholderExprSyntax("name")))]
678+
),
679+
]
680+
)
681+
}
682+
648683
/// Create a diagnostic messages stating that the expression passed to
649684
/// `#require()` is ambiguous.
650685
///

Sources/TestingMacros/TestDeclarationMacro.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ public struct TestDeclarationMacro: PeerMacro, Sendable {
407407
var testsBody: CodeBlockItemListSyntax = """
408408
return [
409409
.__function(
410-
named: \(literal: functionDecl.completeName),
410+
named: \(literal: functionDecl.completeName.trimmedDescription),
411411
in: \(typeNameExpr),
412412
xcTestCompatibleSelector: \(selectorExpr ?? "nil"),
413413
\(raw: attributeInfo.functionArgumentList(in: context)),
@@ -433,7 +433,7 @@ public struct TestDeclarationMacro: PeerMacro, Sendable {
433433
private \(_staticKeyword(for: typeName)) nonisolated func \(unavailableTestName)() async -> [Testing.Test] {
434434
[
435435
.__function(
436-
named: \(literal: functionDecl.completeName),
436+
named: \(literal: functionDecl.completeName.trimmedDescription),
437437
in: \(typeNameExpr),
438438
xcTestCompatibleSelector: \(selectorExpr ?? "nil"),
439439
\(raw: attributeInfo.functionArgumentList(in: context)),

Tests/TestingMacrosTests/TestDeclarationMacroTests.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,21 @@ struct TestDeclarationMacroTests {
209209
),
210210
]
211211
),
212+
213+
#"@Test("Goodbye world") func `__raw__$helloWorld`()"#:
214+
(
215+
message: "Attribute 'Test' specifies display name 'Goodbye world' for function with implicit display name 'helloWorld'",
216+
fixIts: [
217+
ExpectedFixIt(
218+
message: "Remove 'Goodbye world'",
219+
changes: [.replace(oldSourceCode: #""Goodbye world""#, newSourceCode: "")]
220+
),
221+
ExpectedFixIt(
222+
message: "Rename '__raw__$helloWorld'",
223+
changes: [.replace(oldSourceCode: "`__raw__$helloWorld`", newSourceCode: "\(EditorPlaceholderExprSyntax("name"))")]
224+
),
225+
]
226+
),
212227
]
213228
}
214229

@@ -241,6 +256,30 @@ struct TestDeclarationMacroTests {
241256
}
242257
}
243258

259+
@Test("Raw identifier is detected")
260+
func rawIdentifier() {
261+
#expect(TokenSyntax.identifier("`hello`").rawIdentifier == nil)
262+
#expect(TokenSyntax.identifier("`helloworld`").rawIdentifier == nil)
263+
#expect(TokenSyntax.identifier("`hélloworld`").rawIdentifier == nil)
264+
#expect(TokenSyntax.identifier("`hello_world`").rawIdentifier == nil)
265+
#expect(TokenSyntax.identifier("`hello world`").rawIdentifier != nil)
266+
#expect(TokenSyntax.identifier("`hello/world`").rawIdentifier != nil)
267+
#expect(TokenSyntax.identifier("`hello\tworld`").rawIdentifier != nil)
268+
269+
#expect(TokenSyntax.identifier("`class`").rawIdentifier == nil)
270+
#expect(TokenSyntax.identifier("`struct`").rawIdentifier == nil)
271+
#expect(TokenSyntax.identifier("`class struct`").rawIdentifier != nil)
272+
}
273+
274+
@Test("Raw function name components")
275+
func rawFunctionNameComponents() throws {
276+
let decl = """
277+
func `__raw__$hello`(`__raw__$world`: T, etc: U, `blah`: V) {}
278+
""" as DeclSyntax
279+
let functionDecl = try #require(decl.as(FunctionDeclSyntax.self))
280+
#expect(functionDecl.completeName.trimmedDescription == "`hello`(`world`:etc:blah:)")
281+
}
282+
244283
@Test("Warning diagnostics emitted on API misuse",
245284
arguments: [
246285
// return types

Tests/TestingTests/MiscellaneousTests.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,15 @@ struct MiscellaneousTests {
287287
#expect(testType.displayName == "Named Sendable test type")
288288
}
289289

290+
@Test func `__raw__$raw_identifier_provides_a_display_name`() throws {
291+
let test = try #require(Test.current)
292+
#expect(test.displayName == "raw_identifier_provides_a_display_name")
293+
#expect(test.name == "`raw_identifier_provides_a_display_name`()")
294+
let id = test.id
295+
#expect(id.moduleName == "TestingTests")
296+
#expect(id.nameComponents == ["MiscellaneousTests", "`raw_identifier_provides_a_display_name`()"])
297+
}
298+
290299
@Test("Free functions are runnable")
291300
func freeFunction() async throws {
292301
await Test(testFunction: freeSyncFunction).run()

0 commit comments

Comments
 (0)