diff --git a/Sources/Testing/Parameterization/TypeInfo.swift b/Sources/Testing/Parameterization/TypeInfo.swift index ba471b7c8..eeda19418 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 ) @@ -95,10 +95,85 @@ 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]]>() + /// 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 = 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 = 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. + 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 +196,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..3aca54d2f 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,7 @@ 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[.. 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/Support/Additions/TokenSyntaxAdditions.swift b/Sources/TestingMacros/Support/Additions/TokenSyntaxAdditions.swift index 2281f9f5a..12e6abb24 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.isValidSwiftIdentifier(for: .memberAccess) { + 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..aa26b9dc7 100644 --- a/Sources/TestingMacros/Support/DiagnosticMessage.swift +++ b/Sources/TestingMacros/Support/DiagnosticMessage.swift @@ -645,6 +645,41 @@ 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, + hasExtraneousDisplayName displayNameFromAttribute: StringLiteralExprSyntax, + fromArgument argumentContainingDisplayName: LabeledExprListSyntax.Element, + using attribute: AttributeSyntax + ) -> Self { + 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/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 c75166c66..a77acfea1 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"))")] + ), + ] + ), ] } @@ -241,6 +256,30 @@ 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("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 diff --git a/Tests/TestingTests/MiscellaneousTests.swift b/Tests/TestingTests/MiscellaneousTests.swift index a8cd56a7b..49df6cc6e 100644 --- a/Tests/TestingTests/MiscellaneousTests.swift +++ b/Tests/TestingTests/MiscellaneousTests.swift @@ -287,6 +287,15 @@ 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") + #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") func freeFunction() async throws { await Test(testFunction: freeSyncFunction).run() diff --git a/Tests/TestingTests/SourceLocationTests.swift b/Tests/TestingTests/SourceLocationTests.swift index a6a22c3b4..75a8791db 100644 --- a/Tests/TestingTests/SourceLocationTests.swift +++ b/Tests/TestingTests/SourceLocationTests.swift @@ -44,6 +44,20 @@ struct SourceLocationTests { #expect(sourceLocation.moduleName == "FakeModule") } + @Test("SourceLocation.moduleName property with raw identifier", + arguments: [ + ("Foo/Bar.swift", "Foo", "Bar.swift"), + ("`Foo`/Bar.swift", "`Foo`", "Bar.swift"), + ("`Foo.Bar`/Quux.swift", "`Foo.Bar`", "Quux.swift"), + ("`Foo./.Bar`/Quux.swift", "`Foo./.Bar`", "Quux.swift"), + ] + ) + func sourceLocationModuleNameWithRawIdentifier(fileID: String, expectedModuleName: String, expectedFileName: String) throws { + let sourceLocation = SourceLocation(fileID: fileID, filePath: "", line: 1, column: 1) + #expect(sourceLocation.moduleName == expectedModuleName) + #expect(sourceLocation.fileName == expectedFileName) + } + @Test("SourceLocation.fileID property ignores middle components") func sourceLocationFileIDMiddleIgnored() { let sourceLocation = SourceLocation(fileID: "A/B/C/D.swift", filePath: "", line: 1, column: 1) diff --git a/Tests/TestingTests/TypeInfoTests.swift b/Tests/TestingTests/TypeInfoTests.swift index a8d8327b2..b2a79f1ab 100644 --- a/Tests/TestingTests/TypeInfoTests.swift +++ b/Tests/TestingTests/TypeInfoTests.swift @@ -50,6 +50,60 @@ struct TypeInfoTests { #expect(TypeInfo(describing: T.self).fullyQualifiedName == "(Swift.Int, Swift.String) -> 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"]), + ("(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"]), + ("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)