Files
SwiftFormat/Sources/Rules/SwiftTestingTestCaseNames.swift

302 lines
13 KiB
Swift

// Created by Cal Stephens on 2/19/25.
// Copyright © 2025 Airbnb Inc. All rights reserved.
import Foundation
public extension FormatRule {
static let swiftTestingTestCaseNames = FormatRule(
help: "Format Swift Testing @Test and @Suite names.",
options: ["test-case-name-format", "suite-name-format"]
) { formatter in
guard formatter.hasImport("Testing") else { return }
formatter.forEach(.keyword("func")) { funcKeywordIndex, _ in
guard formatter.modifiersForDeclaration(at: funcKeywordIndex, contains: "@Test") else { return }
formatter.removeTestPrefix(fromFunctionAt: funcKeywordIndex)
switch formatter.options.testCaseNameFormat {
case .rawIdentifiers:
guard formatter.options.swiftVersion >= "6.2" else { return }
formatter.convertToRawIdentifier(forDeclarationAt: funcKeywordIndex, macroName: "@Test", upperCamelCase: false)
case .standardIdentifiers:
formatter.convertToStandardIdentifier(forDeclarationAt: funcKeywordIndex, macroName: "@Test", upperCamelCase: false)
case .preserve:
break
}
}
let typeKeywords: [String] = ["struct", "class", "actor", "enum"]
formatter.parseDeclarations().forEachRecursiveDeclaration { declaration in
guard typeKeywords.contains(declaration.keyword) else { return }
guard declaration.hasModifier("@Suite")
|| declaration.body?.contains(where: { $0.keyword == "func" && $0.hasModifier("@Test") }) == true
else { return }
let keywordIndex = declaration.keywordIndex
switch formatter.options.suiteNameFormat {
case .rawIdentifiers:
guard formatter.options.swiftVersion >= "6.2" else { return }
formatter.convertToRawIdentifier(forDeclarationAt: keywordIndex, macroName: "@Suite", upperCamelCase: true)
case .standardIdentifiers:
formatter.convertToStandardIdentifier(forDeclarationAt: keywordIndex, macroName: "@Suite", upperCamelCase: true)
case .preserve:
break
}
}
} examples: {
"""
```diff
import Testing
struct MyFeatureTests {
- @Test func testMyFeatureHasNoBugs() {
+ @Test func `my feature has no bugs`() {
let myFeature = MyFeature()
myFeature.runAction()
#expect(!myFeature.hasBugs, "My feature has no bugs")
#expect(myFeature.crashes.isEmpty, "My feature doesn't crash")
#expect(myFeature.crashReport == nil)
}
- @Test func `test feature works as expected`(_ feature: Feature) {
+ @Test func `feature works as expected`(_ feature: Feature) {
let myFeature = MyFeature()
myFeature.run(feature)
#expect(myFeature.worksAsExpected)
}
}
```
"""
}
}
extension Formatter {
/// Converts a declaration name to use a raw identifier (backtick-quoted name with spaces).
/// If the macro attribute has a display name, uses that as the name and removes it from the attribute.
/// Otherwise, converts the camelCase/underscore name to a space-separated raw identifier.
func convertToRawIdentifier(forDeclarationAt keywordIndex: Int, macroName: String, upperCamelCase _: Bool) {
guard let nameIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: keywordIndex),
tokens[nameIndex].isIdentifier
else { return }
let name = tokens[nameIndex].string
// Check if the macro attribute has a display name argument
if var displayName = macroDisplayName(forDeclarationAt: keywordIndex, macroName: macroName) {
// Remove any existing backticks from the name, since raw identifiers can't contain backticks
displayName = displayName.replacingOccurrences(of: "`", with: "")
let newName = "`\(displayName)`"
updateDeclarationName(forDeclarationAt: keywordIndex, to: newName)
removeMacroDisplayNameString(forDeclarationAt: keywordIndex, macroName: macroName)
} else {
// Convert the name to a raw identifier
let baseName = name.camelCaseToWords()
guard !baseName.isEmpty, baseName != name else { return }
let newName = "`\(baseName)`"
guard tokens[nameIndex] != .identifier(newName) else { return }
updateDeclarationName(forDeclarationAt: keywordIndex, to: newName)
}
}
/// Converts a raw identifier declaration name to a standard identifier, and removes
/// any display name string from the macro attribute.
func convertToStandardIdentifier(forDeclarationAt keywordIndex: Int, macroName: String, upperCamelCase: Bool) {
// Remove display name string from the macro attribute if present
removeMacroDisplayNameString(forDeclarationAt: keywordIndex, macroName: macroName)
guard let nameIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: keywordIndex),
tokens[nameIndex].isIdentifier
else { return }
let name = tokens[nameIndex].string
// Only convert raw identifiers (backtick-quoted names)
guard name.hasPrefix("`"), name.hasSuffix("`") else { return }
let rawName = String(name.dropFirst().dropLast())
let newName = rawName.wordsToIdentifier(upperCamelCase: upperCamelCase)
guard !newName.isEmpty, newName != name else { return }
updateDeclarationName(forDeclarationAt: keywordIndex, to: newName)
}
/// Extracts the display name string from a macro attribute like `@Test("display name")` or `@Suite("display name")`.
func macroDisplayName(forDeclarationAt keywordIndex: Int, macroName: String) -> String? {
var macroAttrIndex: Int?
_ = modifiersForDeclaration(at: keywordIndex, contains: { index, modifier in
if modifier.hasPrefix("\(macroName)(") || modifier.hasPrefix("\(macroName) (") {
macroAttrIndex = index
return true
}
return false
})
guard let attrIndex = macroAttrIndex,
let parenStart = index(of: .startOfScope("("), after: attrIndex),
let firstToken = index(of: .nonSpaceOrCommentOrLinebreak, after: parenStart),
tokens[firstToken] == .startOfScope("\"")
else { return nil }
// Collect string content between the opening and closing quotes
guard let stringEnd = endOfScope(at: firstToken) else { return nil }
// Extract the string literal content (between the quotes)
var displayName = ""
for i in (firstToken + 1) ..< stringEnd {
displayName += tokens[i].string
}
return displayName.isEmpty ? nil : displayName
}
/// Removes the display name from a macro attribute like `@Test("display name")` or `@Suite("display name", ...)`.
func removeMacroDisplayNameString(forDeclarationAt keywordIndex: Int, macroName: String) {
var macroAttrIndex: Int?
_ = modifiersForDeclaration(at: keywordIndex, contains: { index, modifier in
if modifier.hasPrefix("\(macroName)(") || modifier.hasPrefix("\(macroName) (") {
macroAttrIndex = index
return true
}
return false
})
guard let attrIndex = macroAttrIndex,
let parenStart = index(of: .startOfScope("("), after: attrIndex),
let firstToken = index(of: .nonSpaceOrCommentOrLinebreak, after: parenStart),
tokens[firstToken] == .startOfScope("\""),
let stringEnd = endOfScope(at: firstToken)
else { return }
let parenEnd = endOfScope(at: parenStart)!
// Check if there are additional arguments after the display name
if let nextToken = index(of: .nonSpaceOrCommentOrLinebreak, after: stringEnd),
tokens[nextToken] == .delimiter(",")
{
// Remove from the string start through the comma and any trailing space
let removeEnd = index(of: .nonSpaceOrComment, after: nextToken) ?? (nextToken + 1)
removeTokens(in: firstToken ..< removeEnd)
} else {
// This is the only argument remove the entire parentheses
// Also remove any space between the macro and (
let removeStart = (index(of: .nonSpaceOrComment, after: attrIndex) == parenStart)
? (attrIndex + 1) : parenStart
removeTokens(in: removeStart ... parenEnd)
}
}
}
extension String {
/// Converts a method name (camelCase, underscore-separated, or already-backticked) to space-separated words.
/// Returns an empty string if conversion isn't possible.
func camelCaseToWords() -> String {
let baseName = self
// Handle existing raw identifiers: `some name` -> some name
if baseName.hasPrefix("`"), baseName.hasSuffix("`") {
return String(baseName.dropFirst().dropLast())
}
guard !baseName.isEmpty, baseName.first?.isLetter == true else { return "" }
// Split on underscores and camelCase boundaries, then join with spaces
var words: [String] = []
for segment in baseName.split(separator: "_") {
words.append(contentsOf: String(segment).splitCamelCase())
}
// Merge a lone single lowercase leading character with a following all-uppercase word.
// This handles acronym-first names after test prefix removal, e.g. "uUID" (from "testUUID") "UUID".
if words.count >= 2,
words[0].count == 1,
words[0].first?.isLowercase == true,
words[1].allSatisfy(\.isUppercase)
{
words = [words[0].uppercased() + words[1]] + Array(words.dropFirst(2))
}
// Lowercase each word, but preserve all-uppercase words (acronyms like UUID, URL, ABC).
return words.map { $0.allSatisfy(\.isUppercase) ? $0 : $0.lowercased() }.joined(separator: " ")
}
/// Splits a camelCase string into individual words, treating consecutive uppercase letters as acronyms.
/// For example: "UUIDIsValid" ["UUID", "Is", "Valid"], "alphabetStartsWithABC" ["alphabet", "Starts", "With", "ABC"],
/// "greaterThan100" ["greater", "Than", "100"].
func splitCamelCase() -> [String] {
var words: [String] = []
var currentWord = ""
let chars = Array(self)
for i in 0 ..< chars.count {
let char = chars[i]
let nextChar = i + 1 < chars.count ? chars[i + 1] : nil
if char.isUppercase {
if currentWord.isEmpty {
currentWord.append(char)
} else if currentWord.last!.isLowercase || currentWord.last!.isNumber {
// LowerUpper or NumberUpper transition: start a new word
words.append(currentWord)
currentWord = String(char)
} else if let next = nextChar, next.isLowercase {
// Uppercase sequence followed by lowercase: this char starts a new word
// e.g. "UUIDIs" "UUID" + "Is"
words.append(currentWord)
currentWord = String(char)
} else {
// Continue accumulating the uppercase sequence (acronym)
currentWord.append(char)
}
} else if char.isNumber {
if !currentWord.isEmpty, !currentWord.last!.isNumber {
// LetterNumber transition: start a new word
words.append(currentWord)
currentWord = String(char)
} else {
currentWord.append(char)
}
} else {
if !currentWord.isEmpty, currentWord.last!.isNumber {
// NumberLetter transition: start a new word
words.append(currentWord)
currentWord = String(char)
} else {
currentWord.append(char)
}
}
}
if !currentWord.isEmpty {
words.append(currentWord)
}
return words
}
/// Converts a space-separated string to a standard identifier (camelCase or UpperCamelCase).
/// For example: "my test case" -> "myTestCase" (lowerCamelCase) or "MyTestCase" (upperCamelCase).
func wordsToIdentifier(upperCamelCase: Bool) -> String {
let words = split(separator: " ").map(String.init)
guard !words.isEmpty else { return "" }
var result = ""
for (index, word) in words.enumerated() {
guard !word.isEmpty else { continue }
if index == 0, !upperCamelCase {
result += word.prefix(1).lowercased() + word.dropFirst()
} else {
result += word.prefix(1).uppercased() + word.dropFirst()
}
}
return result
}
}