Skip to content

Add support for raw identifiers. #887

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 77 additions & 17 deletions Sources/Testing/Parameterization/TypeInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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<S>(_ 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.
///
Expand All @@ -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
Expand Down
5 changes: 2 additions & 3 deletions Sources/Testing/SourceAttribution/SourceLocation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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[..<firstSlash])
rawIdentifierAwareSplit(fileID, separator: "/", maxSplits: 1).first.map(String.init)!
}

/// The path to the source file.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
//

import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

extension FunctionDeclSyntax {
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
13 changes: 13 additions & 0 deletions Sources/TestingMacros/Support/AttributeDiscovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -109,15 +110,27 @@ 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)
}
}
}

// 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
Expand Down
35 changes: 35 additions & 0 deletions Sources/TestingMacros/Support/DiagnosticMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
4 changes: 2 additions & 2 deletions Sources/TestingMacros/TestDeclarationMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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)),
Expand Down
39 changes: 39 additions & 0 deletions Tests/TestingMacrosTests/TestDeclarationMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))")]
),
]
),
]
}

Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions Tests/TestingTests/MiscellaneousTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading