From 8640814ab32d12c4db42b156f24ae2a50446125a Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Fri, 3 Jan 2025 18:04:10 -0500 Subject: [PATCH 1/8] Add support for raw identifiers. 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). At this time, I _think_ I don't need to make any changes to our macro expansion code for it to compile and run, however I may need to revisit later in order to treat functions with raw identifiers as having "display names" and to synthesize test IDs for them that are concise (CRC-32 to the rescue?) We'll cross that bridge when we come to it, after the feature has landed in the toolchain. Resolves #842. --- .../Testing/Parameterization/TypeInfo.swift | 61 +++++++++++++------ .../SourceAttribution/SourceLocation.swift | 16 ++++- Tests/TestingTests/SourceLocationTests.swift | 14 +++++ Tests/TestingTests/TypeInfoTests.swift | 50 +++++++++++++++ 4 files changed, 121 insertions(+), 20 deletions(-) diff --git a/Sources/Testing/Parameterization/TypeInfo.swift b/Sources/Testing/Parameterization/TypeInfo.swift index ba471b7c8..63bf1b344 100644 --- a/Sources/Testing/Parameterization/TypeInfo.swift +++ b/Sources/Testing/Parameterization/TypeInfo.swift @@ -69,7 +69,7 @@ public struct TypeInfo: Sendable { /// - mangled: The mangled name of the type, if available. init(fullyQualifiedName: String, unqualifiedName: String, mangledName: String?) { self.init( - fullyQualifiedNameComponents: fullyQualifiedName.split(separator: ".").map(String.init), + fullyQualifiedNameComponents: Self.fullyQualifiedNameComponents(ofTypeWithName: fullyQualifiedName), unqualifiedName: unqualifiedName, mangledName: mangledName ) @@ -99,6 +99,48 @@ extension TypeInfo { /// An in-memory cache of fully-qualified type name components. private static let _fullyQualifiedNameComponentsCache = Locked<[ObjectIdentifier: [String]]>() + /// Split the given fully-qualified type name into its components. + /// + /// - Parameters: + /// - fullyQualifiedName: The string to split. + /// + /// - Returns: The components of `fullyQualifiedName` as substrings thereof. + static func fullyQualifiedNameComponents(ofTypeWithName fullyQualifiedName: String) -> [String] { + var components = [Substring]() + + var inRawIdentifier = false + var componentStartIndex = fullyQualifiedName.startIndex + for i in fullyQualifiedName.indices { + let c = fullyQualifiedName[i] + if c == "`" { + inRawIdentifier.toggle() + } else if c == "." && !inRawIdentifier { + components.append(fullyQualifiedName[componentStartIndex ..< i]) + componentStartIndex = fullyQualifiedName.index(after: i) + } + } + components.append(fullyQualifiedName[componentStartIndex...]) + + // If a type is extended in another module and then referenced by name, + // its name according to the String(reflecting:) API will be prefixed with + // "(extension in MODULE_NAME):". For our purposes, we never want to + // preserve that prefix. + if let firstComponent = components.first, firstComponent.starts(with: "(extension in "), + let moduleName = firstComponent.split(separator: ":", maxSplits: 1).last { + // NOTE: even if the module name is a raw identifier, it comprises a + // single identifier (no splitting required) so we don't need to process + // it any further. + components[0] = moduleName + } + + // If a type is private or embedded in a function, its fully qualified + // name may include "(unknown context at $xxxxxxxx)" as a component. Strip + // those out as they're uninteresting to us. + components = components.filter { !$0.starts(with: "(unknown context at") } + + return components.map(String.init) + } + /// The complete name of this type, with the names of all referenced types /// fully-qualified by their module names when possible. /// @@ -121,22 +163,7 @@ extension TypeInfo { return cachedResult } - var result = String(reflecting: type) - .split(separator: ".") - .map(String.init) - - // If a type is extended in another module and then referenced by name, - // its name according to the String(reflecting:) API will be prefixed with - // "(extension in MODULE_NAME):". For our purposes, we never want to - // preserve that prefix. - if let firstComponent = result.first, firstComponent.starts(with: "(extension in ") { - result[0] = String(firstComponent.split(separator: ":", maxSplits: 1).last!) - } - - // If a type is private or embedded in a function, its fully qualified - // name may include "(unknown context at $xxxxxxxx)" as a component. Strip - // those out as they're uninteresting to us. - result = result.filter { !$0.starts(with: "(unknown context at") } + let result = Self.fullyQualifiedNameComponents(ofTypeWithName: String(reflecting: type)) Self._fullyQualifiedNameComponentsCache.withLock { fullyQualifiedNameComponentsCache in fullyQualifiedNameComponentsCache[ObjectIdentifier(type)] = result diff --git a/Sources/Testing/SourceAttribution/SourceLocation.swift b/Sources/Testing/SourceAttribution/SourceLocation.swift index bbf3cf3a6..44fc3fefc 100644 --- a/Sources/Testing/SourceAttribution/SourceLocation.swift +++ b/Sources/Testing/SourceAttribution/SourceLocation.swift @@ -46,7 +46,7 @@ public struct SourceLocation: Sendable { /// - ``moduleName`` public var fileName: String { let lastSlash = fileID.lastIndex(of: "/")! - return String(fileID[fileID.index(after: lastSlash)...]) + return String(fileID[lastSlash...].dropFirst()) } /// The name of the module containing the source file. @@ -67,8 +67,18 @@ public struct SourceLocation: Sendable { /// - ``fileName`` /// - [`#fileID`](https://developer.apple.com/documentation/swift/fileID()) public var moduleName: String { - let firstSlash = fileID.firstIndex(of: "/")! - return String(fileID[.. Swift.Bool") } + @Test("Splitting raw identifiers", + arguments: [ + ("Foo.Bar", ["Foo", "Bar"]), + ("`Foo`.Bar", ["`Foo`", "Bar"]), + ("`Foo`.`Bar`", ["`Foo`", "`Bar`"]), + ("Foo.`Bar`", ["Foo", "`Bar`"]), + ("Foo.`Bar`.Quux", ["Foo", "`Bar`", "Quux"]), + ("Foo.`B.ar`.Quux", ["Foo", "`B.ar`", "Quux"]), + + // These have substrings we intentionally strip out. + ("Foo.`B.ar`.(unknown context at $0).Quux", ["Foo", "`B.ar`", "Quux"]), + ("(extension in Module):Foo.`B.ar`.(unknown context at $0).Quux", ["Foo", "`B.ar`", "Quux"]), + ("(extension in `Module`):Foo.`B.ar`.(unknown context at $0).Quux", ["Foo", "`B.ar`", "Quux"]), + ("(extension in `Module`):`Foo`.`B.ar`.(unknown context at $0).Quux", ["`Foo`", "`B.ar`", "Quux"]), + + // These aren't syntactically valid, but we should at least not crash. + ("Foo.`B.ar`.Quux.`Alpha`..Beta", ["Foo", "`B.ar`", "Quux", "`Alpha`", "", "Beta"]), + ("Foo.`B.ar`.Quux.`Alpha", ["Foo", "`B.ar`", "Quux", "`Alpha"]), + ("Foo.`B.ar`.Quux.`Alpha``", ["Foo", "`B.ar`", "Quux", "`Alpha``"]), + ("Foo.`B.ar`.Quux.`Alpha...", ["Foo", "`B.ar`", "Quux", "`Alpha..."]), + ] + ) + func rawIdentifiers(fqn: String, expectedComponents: [String]) throws { + let actualComponents = TypeInfo.fullyQualifiedNameComponents(ofTypeWithName: fqn) + #expect(expectedComponents == actualComponents) + } + + // As above, but round-tripping through .fullyQualifiedName. + @Test("Round-tripping raw identifiers", + arguments: [ + ("Foo.Bar", ["Foo", "Bar"]), + ("`Foo`.Bar", ["`Foo`", "Bar"]), + ("`Foo`.`Bar`", ["`Foo`", "`Bar`"]), + ("Foo.`Bar`", ["Foo", "`Bar`"]), + ("Foo.`Bar`.Quux", ["Foo", "`Bar`", "Quux"]), + ("Foo.`B.ar`.Quux", ["Foo", "`B.ar`", "Quux"]), + + // These aren't syntactically valid, but we should at least not crash. + ("Foo.`B.ar`.Quux.`Alpha`..Beta", ["Foo", "`B.ar`", "Quux", "`Alpha`", "", "Beta"]), + ("Foo.`B.ar`.Quux.`Alpha", ["Foo", "`B.ar`", "Quux", "`Alpha"]), + ("Foo.`B.ar`.Quux.`Alpha``", ["Foo", "`B.ar`", "Quux", "`Alpha``"]), + ("Foo.`B.ar`.Quux.`Alpha...", ["Foo", "`B.ar`", "Quux", "`Alpha..."]), + ] + ) + func roundTrippedRawIdentifiers(fqn: String, expectedComponents: [String]) throws { + let typeInfo = TypeInfo(fullyQualifiedName: fqn, unqualifiedName: "", mangledName: "") + #expect(typeInfo.fullyQualifiedName == fqn) + #expect(typeInfo.fullyQualifiedNameComponents == expectedComponents) + } + @available(_mangledTypeNameAPI, *) @Test func mangledTypeName() { #expect(_mangledTypeName(String.self) == TypeInfo(describing: String.self).mangledName) From d4f5152214dbe5a09d06f34870c4dbac913983f2 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Sat, 4 Jan 2025 13:55:30 -0500 Subject: [PATCH 2/8] Ensure we correctly handle raw identifiers in '(extension in' substrings --- .../Testing/Parameterization/TypeInfo.swift | 63 ++++++++++++++----- .../SourceAttribution/SourceLocation.swift | 13 +--- Tests/TestingTests/TypeInfoTests.swift | 2 + 3 files changed, 51 insertions(+), 27 deletions(-) diff --git a/Sources/Testing/Parameterization/TypeInfo.swift b/Sources/Testing/Parameterization/TypeInfo.swift index 63bf1b344..eeda19418 100644 --- a/Sources/Testing/Parameterization/TypeInfo.swift +++ b/Sources/Testing/Parameterization/TypeInfo.swift @@ -95,6 +95,52 @@ public struct TypeInfo: Sendable { // MARK: - Name +/// Split a string with a separator while respecting raw identifiers and their +/// enclosing backtick characters. +/// +/// - Parameters: +/// - string: The string to split. +/// - separator: The character that separates components of `string`. +/// - maxSplits: The maximum number of splits to perform on `string`. The +/// resulting array contains up to `maxSplits + 1` elements. +/// +/// - Returns: An array of substrings of `string`. +/// +/// Unlike `String.split(separator:maxSplits:omittingEmptySubsequences:)`, this +/// function does not split the string on separator characters that occur +/// between pairs of backtick characters. This is useful when splitting strings +/// containing raw identifiers. +/// +/// - Complexity: O(_n_), where _n_ is the length of `string`. +func rawIdentifierAwareSplit(_ string: S, separator: Character, maxSplits: Int = .max) -> [S.SubSequence] where S: StringProtocol { + var result = [S.SubSequence]() + + var inRawIdentifier = false + var componentStartIndex = string.startIndex + for i in string.indices { + let c = string[i] + if c == "`" { + // We are either entering or exiting a raw identifier. While inside a raw + // identifier, separator characters are ignored. + inRawIdentifier.toggle() + } else if c == separator && !inRawIdentifier { + // Add everything up to this separator as the next component, then start + // a new component after the separator. + result.append(string[componentStartIndex ..< i]) + componentStartIndex = string.index(after: i) + + if result.count == maxSplits { + // We don't need to find more separators. We'll add the remainder of the + // string outside the loop as the last component, then return. + break + } + } + } + result.append(string[componentStartIndex...]) + + return result +} + extension TypeInfo { /// An in-memory cache of fully-qualified type name components. private static let _fullyQualifiedNameComponentsCache = Locked<[ObjectIdentifier: [String]]>() @@ -106,27 +152,14 @@ extension TypeInfo { /// /// - Returns: The components of `fullyQualifiedName` as substrings thereof. static func fullyQualifiedNameComponents(ofTypeWithName fullyQualifiedName: String) -> [String] { - var components = [Substring]() - - var inRawIdentifier = false - var componentStartIndex = fullyQualifiedName.startIndex - for i in fullyQualifiedName.indices { - let c = fullyQualifiedName[i] - if c == "`" { - inRawIdentifier.toggle() - } else if c == "." && !inRawIdentifier { - components.append(fullyQualifiedName[componentStartIndex ..< i]) - componentStartIndex = fullyQualifiedName.index(after: i) - } - } - components.append(fullyQualifiedName[componentStartIndex...]) + var components = rawIdentifierAwareSplit(fullyQualifiedName, separator: ".") // If a type is extended in another module and then referenced by name, // its name according to the String(reflecting:) API will be prefixed with // "(extension in MODULE_NAME):". For our purposes, we never want to // preserve that prefix. if let firstComponent = components.first, firstComponent.starts(with: "(extension in "), - let moduleName = firstComponent.split(separator: ":", maxSplits: 1).last { + let moduleName = rawIdentifierAwareSplit(firstComponent, separator: ":", maxSplits: 1).last { // NOTE: even if the module name is a raw identifier, it comprises a // single identifier (no splitting required) so we don't need to process // it any further. diff --git a/Sources/Testing/SourceAttribution/SourceLocation.swift b/Sources/Testing/SourceAttribution/SourceLocation.swift index 44fc3fefc..3aca54d2f 100644 --- a/Sources/Testing/SourceAttribution/SourceLocation.swift +++ b/Sources/Testing/SourceAttribution/SourceLocation.swift @@ -67,18 +67,7 @@ public struct SourceLocation: Sendable { /// - ``fileName`` /// - [`#fileID`](https://developer.apple.com/documentation/swift/fileID()) public var moduleName: String { - var inRawIdentifier = false - for i in fileID.indices { - let c = fileID[i] - if c == "`" { - inRawIdentifier.toggle() - } else if c == "/" && !inRawIdentifier { - return String(fileID[.. Date: Mon, 6 Jan 2025 13:38:19 -0500 Subject: [PATCH 3/8] Incorporate feedback --- Tests/TestingTests/TypeInfoTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/TestingTests/TypeInfoTests.swift b/Tests/TestingTests/TypeInfoTests.swift index 1d600a255..b2a79f1ab 100644 --- a/Tests/TestingTests/TypeInfoTests.swift +++ b/Tests/TestingTests/TypeInfoTests.swift @@ -66,6 +66,8 @@ struct TypeInfoTests { ("(extension in `Module`):`Foo`.`B.ar`.(unknown context at $0).Quux", ["`Foo`", "`B.ar`", "Quux"]), ("(extension in `Mo:dule`):`Foo`.`B.ar`.(unknown context at $0).Quux", ["`Foo`", "`B.ar`", "Quux"]), ("(extension in `Module`):`F:oo`.`B.ar`.(unknown context at $0).Quux", ["`F:oo`", "`B.ar`", "Quux"]), + ("`(extension in Foo):Bar`.Baz", ["`(extension in Foo):Bar`", "Baz"]), + ("(extension in `(extension in Foo2):Bar2`):`(extension in Foo):Bar`.Baz", ["`(extension in Foo):Bar`", "Baz"]), // These aren't syntactically valid, but we should at least not crash. ("Foo.`B.ar`.Quux.`Alpha`..Beta", ["Foo", "`B.ar`", "Quux", "`Alpha`", "", "Beta"]), From aae1d08759d83800381a106e6c43353eb6529e60 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 6 Jan 2025 15:09:08 -0500 Subject: [PATCH 4/8] Derive a display name from a raw identifier and disallow raw IDs with explicit display name string literals --- .../Additions/TokenSyntaxAdditions.swift | 30 ++++++++++++++++++- .../Support/AttributeDiscovery.swift | 13 ++++++++ .../Support/DiagnosticMessage.swift | 28 +++++++++++++++++ .../TestDeclarationMacroTests.swift | 15 ++++++++++ Tests/TestingTests/MiscellaneousTests.swift | 5 ++++ 5 files changed, 90 insertions(+), 1 deletion(-) diff --git a/Sources/TestingMacros/Support/Additions/TokenSyntaxAdditions.swift b/Sources/TestingMacros/Support/Additions/TokenSyntaxAdditions.swift index 2281f9f5a..be7cf6653 100644 --- a/Sources/TestingMacros/Support/Additions/TokenSyntaxAdditions.swift +++ b/Sources/TestingMacros/Support/Additions/TokenSyntaxAdditions.swift @@ -11,11 +11,39 @@ import SwiftSyntax extension TokenSyntax { + /// A tuple containing the text of this instance with enclosing backticks + /// removed and whether or not they were removed. + private var _textWithoutBackticks: (String, backticksRemoved: Bool) { + let text = text + if case .identifier = tokenKind, text.first == "`" && text.last == "`" && text.count >= 2 { + return (String(text.dropFirst().dropLast()), true) + } + + return (text, false) + } + /// The text of this instance with all backticks removed. /// /// - Bug: This property works around the presence of backticks in `text.` /// ([swift-syntax-#1936](https://github.com/swiftlang/swift-syntax/issues/1936)) var textWithoutBackticks: String { - text.filter { $0 != "`" } + _textWithoutBackticks.0 + } + + /// The raw identifier, not including enclosing backticks, represented by this + /// token, or `nil` if it does not represent one. + var rawIdentifier: String? { + let (textWithoutBackticks, backticksRemoved) = _textWithoutBackticks + if backticksRemoved, textWithoutBackticks.contains(where: \.isWhitespace) { + return textWithoutBackticks + } + + // TODO: remove this mock path once the toolchain fully supports raw IDs. + let mockPrefix = "__raw__$" + if backticksRemoved, textWithoutBackticks.starts(with: mockPrefix) { + return String(textWithoutBackticks.dropFirst(mockPrefix.count)) + } + + return nil } } diff --git a/Sources/TestingMacros/Support/AttributeDiscovery.swift b/Sources/TestingMacros/Support/AttributeDiscovery.swift index 77b2b174e..dce4bddd3 100644 --- a/Sources/TestingMacros/Support/AttributeDiscovery.swift +++ b/Sources/TestingMacros/Support/AttributeDiscovery.swift @@ -100,6 +100,7 @@ struct AttributeInfo { init(byParsing attribute: AttributeSyntax, on declaration: some SyntaxProtocol, in context: some MacroExpansionContext) { self.attribute = attribute + var displayNameArgument: LabeledExprListSyntax.Element? var nonDisplayNameArguments: [Argument] = [] if let arguments = attribute.arguments, case let .argumentList(argumentList) = arguments { // If the first argument is an unlabelled string literal, it's the display @@ -109,8 +110,10 @@ struct AttributeInfo { let firstArgumentHasLabel = (firstArgument.label != nil) if !firstArgumentHasLabel, let stringLiteral = firstArgument.expression.as(StringLiteralExprSyntax.self) { displayName = stringLiteral + displayNameArgument = firstArgument nonDisplayNameArguments = argumentList.dropFirst().map(Argument.init) } else if !firstArgumentHasLabel, firstArgument.expression.is(NilLiteralExprSyntax.self) { + displayNameArgument = firstArgument nonDisplayNameArguments = argumentList.dropFirst().map(Argument.init) } else { nonDisplayNameArguments = argumentList.map(Argument.init) @@ -118,6 +121,16 @@ struct AttributeInfo { } } + // Disallow an explicit display name for tests and suites with raw + // identifier names as it's redundant and potentially confusing. + if let namedDecl = declaration.asProtocol((any NamedDeclSyntax).self), + let rawIdentifier = namedDecl.name.rawIdentifier { + if let displayName, let displayNameArgument { + context.diagnose(.declaration(namedDecl, hasExtraneousDisplayName: displayName, fromArgument: displayNameArgument, using: attribute)) + } + displayName = StringLiteralExprSyntax(content: rawIdentifier) + } + // Remove leading "Self." expressions from the arguments of the attribute. // See _SelfRemover for more information. Rewriting a syntax tree discards // location information from the copy, so only invoke the rewriter if the diff --git a/Sources/TestingMacros/Support/DiagnosticMessage.swift b/Sources/TestingMacros/Support/DiagnosticMessage.swift index 7d8a83c20..1f8679ca7 100644 --- a/Sources/TestingMacros/Support/DiagnosticMessage.swift +++ b/Sources/TestingMacros/Support/DiagnosticMessage.swift @@ -645,6 +645,34 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { ) } + /// Create a diagnostic message stating that a declaration has two display + /// names. + /// + /// - Returns: A diagnostic message. + static func declaration( + _ decl: some NamedDeclSyntax, + hasExtraneousDisplayName displayNameFromAttribute: StringLiteralExprSyntax, + fromArgument argumentContainingDisplayName: LabeledExprListSyntax.Element, + using attribute: AttributeSyntax + ) -> Self { + // FIXME: implement fixits + Self( + syntax: Syntax(decl), + message: "Attribute \(_macroName(attribute)) specifies display name '\(displayNameFromAttribute.representedLiteralValue!)' for \(_kindString(for: decl)) with implicit display name '\(decl.name.rawIdentifier!)'", + severity: .error, + fixIts: [ + FixIt( + message: MacroExpansionFixItMessage("Remove '\(displayNameFromAttribute.representedLiteralValue!)'"), + changes: [.replace(oldNode: Syntax(argumentContainingDisplayName), newNode: Syntax("" as ExprSyntax))] + ), + FixIt( + message: MacroExpansionFixItMessage("Rename '\(decl.name.textWithoutBackticks)'"), + changes: [.replace(oldNode: Syntax(decl.name), newNode: Syntax(EditorPlaceholderExprSyntax("name")))] + ), + ] + ) + } + /// Create a diagnostic messages stating that the expression passed to /// `#require()` is ambiguous. /// diff --git a/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift b/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift index c75166c66..bb3e1cb16 100644 --- a/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift +++ b/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift @@ -209,6 +209,21 @@ struct TestDeclarationMacroTests { ), ] ), + + #"@Test("Goodbye world") func `__raw__$helloWorld`()"#: + ( + message: "Attribute 'Test' specifies display name 'Goodbye world' for function with implicit display name 'helloWorld'", + fixIts: [ + ExpectedFixIt( + message: "Remove 'Goodbye world'", + changes: [.replace(oldSourceCode: #""Goodbye world""#, newSourceCode: "")] + ), + ExpectedFixIt( + message: "Rename '__raw__$helloWorld'", + changes: [.replace(oldSourceCode: "`__raw__$helloWorld`", newSourceCode: "\(EditorPlaceholderExprSyntax("name"))")] + ), + ] + ), ] } diff --git a/Tests/TestingTests/MiscellaneousTests.swift b/Tests/TestingTests/MiscellaneousTests.swift index a8cd56a7b..693205f59 100644 --- a/Tests/TestingTests/MiscellaneousTests.swift +++ b/Tests/TestingTests/MiscellaneousTests.swift @@ -287,6 +287,11 @@ struct MiscellaneousTests { #expect(testType.displayName == "Named Sendable test type") } + @Test func `__raw__$raw_identifier_provides_a_display_name`() throws { + let test = try #require(Test.current) + #expect(test.displayName == "raw_identifier_provides_a_display_name") + } + @Test("Free functions are runnable") func freeFunction() async throws { await Test(testFunction: freeSyncFunction).run() From 81478ad187278db173c4e477459b89e8a751cad2 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 6 Jan 2025 15:51:33 -0500 Subject: [PATCH 5/8] Use isValidSwiftIdentifier(), add more tests --- .../Support/Additions/TokenSyntaxAdditions.swift | 2 +- .../TestDeclarationMacroTests.swift | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Sources/TestingMacros/Support/Additions/TokenSyntaxAdditions.swift b/Sources/TestingMacros/Support/Additions/TokenSyntaxAdditions.swift index be7cf6653..12e6abb24 100644 --- a/Sources/TestingMacros/Support/Additions/TokenSyntaxAdditions.swift +++ b/Sources/TestingMacros/Support/Additions/TokenSyntaxAdditions.swift @@ -34,7 +34,7 @@ extension TokenSyntax { /// token, or `nil` if it does not represent one. var rawIdentifier: String? { let (textWithoutBackticks, backticksRemoved) = _textWithoutBackticks - if backticksRemoved, textWithoutBackticks.contains(where: \.isWhitespace) { + if backticksRemoved, !textWithoutBackticks.isValidSwiftIdentifier(for: .memberAccess) { return textWithoutBackticks } diff --git a/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift b/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift index bb3e1cb16..4f85aed17 100644 --- a/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift +++ b/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift @@ -256,6 +256,21 @@ struct TestDeclarationMacroTests { } } + @Test("Raw identifier is detected") + func rawIdentifier() { + #expect(TokenSyntax.identifier("`hello`").rawIdentifier == nil) + #expect(TokenSyntax.identifier("`helloworld`").rawIdentifier == nil) + #expect(TokenSyntax.identifier("`hélloworld`").rawIdentifier == nil) + #expect(TokenSyntax.identifier("`hello_world`").rawIdentifier == nil) + #expect(TokenSyntax.identifier("`hello world`").rawIdentifier != nil) + #expect(TokenSyntax.identifier("`hello/world`").rawIdentifier != nil) + #expect(TokenSyntax.identifier("`hello\tworld`").rawIdentifier != nil) + + #expect(TokenSyntax.identifier("`class`").rawIdentifier == nil) + #expect(TokenSyntax.identifier("`struct`").rawIdentifier == nil) + #expect(TokenSyntax.identifier("`class struct`").rawIdentifier != nil) + } + @Test("Warning diagnostics emitted on API misuse", arguments: [ // return types From 838eab9e7bc9ad0cb06b7b86bcaded0ade834c33 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 6 Jan 2025 16:39:03 -0500 Subject: [PATCH 6/8] Handle complete function names with raw pieces --- .../FunctionDeclSyntaxAdditions.swift | 25 +++++++++++++------ .../TestingMacros/TestDeclarationMacro.swift | 4 +-- .../TestDeclarationMacroTests.swift | 9 +++++++ 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift b/Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift index 1990356da..9b9378283 100644 --- a/Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift +++ b/Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift @@ -9,6 +9,7 @@ // import SwiftSyntax +import SwiftSyntaxBuilder import SwiftSyntaxMacros extension FunctionDeclSyntax { @@ -35,16 +36,24 @@ extension FunctionDeclSyntax { /// The name of this function including parentheses, parameter labels, and /// colons. - var completeName: String { - var result = [name.textWithoutBackticks, "(",] - - for parameter in signature.parameterClause.parameters { - result.append(parameter.firstName.textWithoutBackticks) - result.append(":") + var completeName: DeclReferenceExprSyntax { + func possiblyRaw(_ token: TokenSyntax) -> TokenSyntax { + if let rawIdentifier = token.rawIdentifier { + return .identifier("`\(rawIdentifier)`") + } + return .identifier(token.textWithoutBackticks) } - result.append(")") - return result.joined() + return DeclReferenceExprSyntax( + baseName: possiblyRaw(name), + argumentNames: DeclNameArgumentsSyntax( + arguments: DeclNameArgumentListSyntax { + for parameter in signature.parameterClause.parameters { + DeclNameArgumentSyntax(name: possiblyRaw(parameter.firstName)) + } + } + ) + ) } /// An array of tuples representing this function's parameters. diff --git a/Sources/TestingMacros/TestDeclarationMacro.swift b/Sources/TestingMacros/TestDeclarationMacro.swift index 463412d2a..1a3f2c448 100644 --- a/Sources/TestingMacros/TestDeclarationMacro.swift +++ b/Sources/TestingMacros/TestDeclarationMacro.swift @@ -407,7 +407,7 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { var testsBody: CodeBlockItemListSyntax = """ return [ .__function( - named: \(literal: functionDecl.completeName), + named: \(literal: functionDecl.completeName.trimmedDescription), in: \(typeNameExpr), xcTestCompatibleSelector: \(selectorExpr ?? "nil"), \(raw: attributeInfo.functionArgumentList(in: context)), @@ -433,7 +433,7 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { private \(_staticKeyword(for: typeName)) nonisolated func \(unavailableTestName)() async -> [Testing.Test] { [ .__function( - named: \(literal: functionDecl.completeName), + named: \(literal: functionDecl.completeName.trimmedDescription), in: \(typeNameExpr), xcTestCompatibleSelector: \(selectorExpr ?? "nil"), \(raw: attributeInfo.functionArgumentList(in: context)), diff --git a/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift b/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift index 4f85aed17..a77acfea1 100644 --- a/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift +++ b/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift @@ -271,6 +271,15 @@ struct TestDeclarationMacroTests { #expect(TokenSyntax.identifier("`class struct`").rawIdentifier != nil) } + @Test("Raw function name components") + func rawFunctionNameComponents() throws { + let decl = """ + func `__raw__$hello`(`__raw__$world`: T, etc: U, `blah`: V) {} + """ as DeclSyntax + let functionDecl = try #require(decl.as(FunctionDeclSyntax.self)) + #expect(functionDecl.completeName.trimmedDescription == "`hello`(`world`:etc:blah:)") + } + @Test("Warning diagnostics emitted on API misuse", arguments: [ // return types From 11ef9ff169d3b7dd48b5a11aff18aa584bcaba17 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 7 Jan 2025 19:15:52 -0500 Subject: [PATCH 7/8] Add a bit more testing --- Sources/TestingMacros/Support/DiagnosticMessage.swift | 1 - Tests/TestingTests/MiscellaneousTests.swift | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/TestingMacros/Support/DiagnosticMessage.swift b/Sources/TestingMacros/Support/DiagnosticMessage.swift index 1f8679ca7..c94076502 100644 --- a/Sources/TestingMacros/Support/DiagnosticMessage.swift +++ b/Sources/TestingMacros/Support/DiagnosticMessage.swift @@ -655,7 +655,6 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { fromArgument argumentContainingDisplayName: LabeledExprListSyntax.Element, using attribute: AttributeSyntax ) -> Self { - // FIXME: implement fixits Self( syntax: Syntax(decl), message: "Attribute \(_macroName(attribute)) specifies display name '\(displayNameFromAttribute.representedLiteralValue!)' for \(_kindString(for: decl)) with implicit display name '\(decl.name.rawIdentifier!)'", diff --git a/Tests/TestingTests/MiscellaneousTests.swift b/Tests/TestingTests/MiscellaneousTests.swift index 693205f59..49df6cc6e 100644 --- a/Tests/TestingTests/MiscellaneousTests.swift +++ b/Tests/TestingTests/MiscellaneousTests.swift @@ -290,6 +290,10 @@ struct MiscellaneousTests { @Test func `__raw__$raw_identifier_provides_a_display_name`() throws { let test = try #require(Test.current) #expect(test.displayName == "raw_identifier_provides_a_display_name") + #expect(test.name == "`raw_identifier_provides_a_display_name`()") + let id = test.id + #expect(id.moduleName == "TestingTests") + #expect(id.nameComponents == ["MiscellaneousTests", "`raw_identifier_provides_a_display_name`()"]) } @Test("Free functions are runnable") From 35f6d80de1c5b8f07dd345befc31783d1947277f Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Wed, 8 Jan 2025 08:36:49 -0500 Subject: [PATCH 8/8] Update markup for diagnostic --- Sources/TestingMacros/Support/DiagnosticMessage.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/TestingMacros/Support/DiagnosticMessage.swift b/Sources/TestingMacros/Support/DiagnosticMessage.swift index c94076502..aa26b9dc7 100644 --- a/Sources/TestingMacros/Support/DiagnosticMessage.swift +++ b/Sources/TestingMacros/Support/DiagnosticMessage.swift @@ -648,6 +648,14 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { /// Create a diagnostic message stating that a declaration has two display /// names. /// + /// - Parameters: + /// - decl: The declaration that has two display names. + /// - displayNameFromAttribute: The display name provided by the `@Test` or + /// `@Suite` attribute. + /// - argumentContainingDisplayName: The argument node containing the node + /// `displayNameFromAttribute`. + /// - attribute: The `@Test` or `@Suite` attribute. + /// /// - Returns: A diagnostic message. static func declaration( _ decl: some NamedDeclSyntax,