mirror of
https://github.com/nicklockwood/SwiftFormat.git
synced 2026-05-17 10:30:35 +00:00
170 lines
5.7 KiB
Swift
170 lines
5.7 KiB
Swift
//
|
|
// ValidateTestCases.swift
|
|
// SwiftFormat
|
|
//
|
|
// Created by Cal Stephens on 10/15/25.
|
|
// Copyright © 2025 Nick Lockwood. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
public extension FormatRule {
|
|
static let validateTestCases = FormatRule(
|
|
help: "Ensure test case methods have the correct `test` prefix or `@Test` attribute.",
|
|
disabledByDefault: true
|
|
) { formatter in
|
|
guard let testFramework = formatter.detectTestingFramework() else {
|
|
return
|
|
}
|
|
|
|
let declarations = formatter.parseDeclarations()
|
|
let testClasses = declarations.compactMap(\.asTypeDeclaration).filter { typeDecl in
|
|
formatter.isSimpleTestSuite(typeDecl, for: testFramework)
|
|
}
|
|
|
|
for testClass in testClasses {
|
|
for member in testClass.body where member.keyword == "func" {
|
|
if formatter.isLikelyTestCase(member, for: testFramework) {
|
|
switch testFramework {
|
|
case .xcTest:
|
|
formatter.addTestPrefixIfNeeded(member)
|
|
case .swiftTesting:
|
|
formatter.addTestAttributeIfNeeded(member)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} examples: {
|
|
"""
|
|
```diff
|
|
import XCTest
|
|
|
|
final class MyTests: XCTestCase {
|
|
- func myFeatureWorksCorrectly() {
|
|
+ func testMyFeatureWorksCorrectly() {
|
|
XCTAssertTrue(myFeature.worksCorrectly)
|
|
}
|
|
}
|
|
```
|
|
|
|
```diff
|
|
import Testing
|
|
|
|
struct MyFeatureTests {
|
|
- func testMyFeatureWorksCorrectly() {
|
|
+ @Test func myFeatureWorksCorrectly() {
|
|
#expect(myFeature.worksCorrectly)
|
|
}
|
|
|
|
- func myFeatureHasNoBugs() {
|
|
+ @Test func myFeatureHasNoBugs() {
|
|
#expect(myFeature.hasNoBugs)
|
|
}
|
|
}
|
|
```
|
|
"""
|
|
}
|
|
}
|
|
|
|
extension Formatter {
|
|
/// Determines if a function should be treated as a test case, even if it's currently missing
|
|
/// its `test` prefix or `@Test` attribute.
|
|
func isLikelyTestCase(
|
|
_ function: Declaration,
|
|
for framework: TestingFramework
|
|
) -> Bool {
|
|
guard let functionDecl = parseFunctionDeclaration(keywordIndex: function.keywordIndex) else {
|
|
return false
|
|
}
|
|
|
|
let modifiers = function.modifiers
|
|
|
|
// Skip if it's an override, has @objc, or is static (might be called from outside)
|
|
if modifiers.contains("override") || modifiers.contains("@objc") || modifiers.contains("static") {
|
|
return false
|
|
}
|
|
|
|
// Get function name
|
|
guard let nameIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: function.keywordIndex),
|
|
case let .identifier(name) = tokens[nameIndex]
|
|
else { return false }
|
|
|
|
// If method has a disabled test prefix, it's not a test
|
|
if hasDisabledPrefix(name) {
|
|
return false
|
|
}
|
|
|
|
// Skip if it's explicitly private (definitely not a test)
|
|
if modifiers.contains("private") || modifiers.contains("fileprivate") {
|
|
return false
|
|
}
|
|
|
|
// Check if this is already marked as a test
|
|
let hasTestAttribute = modifiers.contains("@Test")
|
|
|
|
// A function with test signature (no params, no return type) should be treated as a test
|
|
let hasTestSignature = functionDecl.arguments.isEmpty && functionDecl.returnType == nil
|
|
|
|
// For Swift Testing: treat as test if it has @Test or test signature
|
|
if framework == .swiftTesting {
|
|
return hasTestAttribute || hasTestSignature
|
|
}
|
|
|
|
// For XCTest: treat as test if it has test signature AND isn't referenced elsewhere
|
|
// Check if the function name appears more than once (definition + at least one reference)
|
|
var occurrences = 0
|
|
for token in tokens {
|
|
if case let .identifier(existingName) = token, existingName == name {
|
|
occurrences += 1
|
|
if occurrences > 1 {
|
|
// Found at least 2 occurrences (definition + reference), so it's a helper method
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
return hasTestSignature
|
|
}
|
|
|
|
/// Ensures a function has a "test" prefix.
|
|
func addTestPrefixIfNeeded(_ function: Declaration) {
|
|
guard let nameIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: function.keywordIndex),
|
|
case let .identifier(name) = tokens[nameIndex]
|
|
else { return }
|
|
|
|
// If it already has a "test" prefix, do nothing
|
|
if name.hasPrefix("test") {
|
|
return
|
|
}
|
|
|
|
// Check if the new name would create a collision
|
|
let newName = "test" + name.prefix(1).uppercased() + name.dropFirst()
|
|
let existingIdentifiers = Set(tokens.compactMap { token -> String? in
|
|
if case let .identifier(name) = token {
|
|
return name
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// If the new name already exists elsewhere, don't rename
|
|
if existingIdentifiers.contains(newName) {
|
|
return
|
|
}
|
|
|
|
// Add "test" prefix to the function name
|
|
replaceToken(at: nameIndex, with: .identifier(newName))
|
|
}
|
|
|
|
/// Ensures a function has the @Test attribute.
|
|
func addTestAttributeIfNeeded(_ function: Declaration) {
|
|
// Check if the function already has @Test attribute
|
|
if modifiersForDeclaration(at: function.keywordIndex, contains: "@Test") {
|
|
return
|
|
}
|
|
|
|
// Add @Test attribute before the function
|
|
let insertIndex = startOfModifiers(at: function.keywordIndex, includingAttributes: true)
|
|
insert(tokenize("@Test "), at: insertIndex)
|
|
}
|
|
}
|