Skip to content

Commit

Permalink
Add rule to prefer Swift Testing over XCTest
Browse files Browse the repository at this point in the history
  • Loading branch information
calda authored and nicklockwood committed Jan 27, 2025
1 parent 19bf26c commit cc74a69
Show file tree
Hide file tree
Showing 9 changed files with 1,350 additions and 73 deletions.
73 changes: 73 additions & 0 deletions Rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
* [redundantProperty](#redundantProperty)
* [sortSwitchCases](#sortSwitchCases)
* [spacingGuards](#spacingGuards)
* [swiftTesting](#swiftTesting)
* [unusedPrivateDeclarations](#unusedPrivateDeclarations)
* [wrapConditionalBodies](#wrapConditionalBodies)
* [wrapEnumCases](#wrapEnumCases)
Expand Down Expand Up @@ -2916,6 +2917,78 @@ set to 4.2 or above.
</details>
<br/>

## swiftTesting

Prefer the Swift Testing library over XCTest.

<details>
<summary>Examples</summary>

```diff
@testable import MyFeatureLib
- import XCTest
+ import Testing

- final class MyFeatureTests: XCTestCase {
- func testMyFeatureHasNoBugs() {
- let myFeature = MyFeature()
- myFeature.runAction()
- XCTAssertFalse(myFeature.hasBugs, "My feature has no bugs")
- XCTAssertEqual(myFeature.crashes.count, 0, "My feature doesn't crash")
- XCTAssertNil(myFeature.crashReport)
- }
- }
+ @MainActor
+ final class MyFeatureTests {
+ @Test func myFeatureHasNoBugs() {
+ 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)
+ }
+ }

- final class MyFeatureTests: XCTestCase {
- var myFeature: MyFeature!
-
- override func setUp() async throws {
- myFeature = try await MyFeature()
- }
-
- override func tearDown() {
- myFeature = nil
- }
-
- func testMyFeatureWorks() {
- myFeature.runAction()
- XCTAssertTrue(myFeature.worksProperly)
- XCTAssertEqual(myFeature.screens.count, 8)
- }
- }
+ @MainActor
+ final class MyFeatureTests {
+ var myFeature: MyFeature!
+
+ init() async throws {
+ myFeature = try await MyFeature()
+ }
+
+ deinit {
+ myFeature = nil
+ }
+
+ @Test func myFeatureWorks() {
+ myFeature.runAction()
+ #expect(myFeature.worksProperly)
+ #expect(myFeature.screens.count == 8)
+ }
+ }
```

</details>
<br/>

## todos

Use correct formatting for `TODO:`, `MARK:` or `FIXME:` comments.
Expand Down
100 changes: 95 additions & 5 deletions Sources/ParsingHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2014,7 +2014,7 @@ extension Formatter {
switch tokens[nextPartIndex] {
case .operator(".", .infix):
name += "."
case let .identifier(string):
case let .identifier(string) where name.hasSuffix("."):
name += string
default:
break loop
Expand Down Expand Up @@ -2058,6 +2058,57 @@ extension Formatter {
return importStack
}

/// Adds imports for the given list of modules to this file if not already present
func addImports(_ importsToAddIfNeeded: Set<String>) {
let importRanges = parseImports()
let currentImports = Set(importRanges.flatMap { $0.map(\.module) })

for importToAddIfNeeded in importsToAddIfNeeded {
guard !currentImports.contains(importToAddIfNeeded) else { continue }

let newImport: [Token] = [.keyword("import"), .space(" "), .identifier(importToAddIfNeeded)]

// If there are any existing imports, add the new import in the existing group
if let firstImportIndex = index(of: .keyword("import"), after: -1) {
let startOfFirstImport = startOfModifiers(at: firstImportIndex, includingAttributes: true)
insert(newImport + [linebreakToken(for: firstImportIndex)], at: startOfFirstImport)
}

// Otherwise if there are no imports:
// - Make sure to insert the comment after any header comment if present
// - Include a blank line after the import
else {
let insertionIndex: Int
if let headerCommentRange = headerCommentTokenRange(), !headerCommentRange.isEmpty {
insertionIndex = headerCommentRange.upperBound
} else {
insertionIndex = 0
}

let newImportWithBlankLine = newImport + [
linebreakToken(for: insertionIndex),
linebreakToken(for: insertionIndex),
]

insert(newImportWithBlankLine, at: insertionIndex)
}
}
}

/// Removes the import for the given module names if present
func removeImports(_ moduleNames: Set<String>) {
let imports = parseImports().flatMap { $0 }
let importsToRemove = imports.filter { moduleNames.contains($0.module) }

let importsToRemoveByFileOrder = importsToRemove.sorted(by: { lhs, rhs in
lhs.range.lowerBound < rhs.range.lowerBound
})

for importToRemove in importsToRemoveByFileOrder.reversed() {
removeTokens(in: importToRemove.range)
}
}

/// Parses the arguments of the closure whose open brace is at the given index.
/// Returns `nil` if this is an anonymous closure, or if there was an issue parsing the closure arguments.
/// - `{ foo in ... }` returns `argumentNames: ["foo"]`
Expand Down Expand Up @@ -2532,15 +2583,22 @@ extension Formatter {
return arguments
}

struct FunctionCallArgument {
/// The label of the argument. `nil` if unlabeled.
let label: String?
/// The value of the argument, including any leading or trailing whitespace / comments.
let value: String
}

/// Parses the parameter labels of the function call with its `(` start of scope
/// token at the given index.
func parseFunctionCallArgumentLabels(startOfScope: Int) -> [String?] {
func parseFunctionCallArguments(startOfScope: Int) -> [FunctionCallArgument] {
assert(tokens[startOfScope] == .startOfScope("("))
guard let endOfScope = endOfScope(at: startOfScope),
index(of: .nonSpaceOrCommentOrLinebreak, after: startOfScope) != endOfScope
else { return [] }

var argumentLabels: [String?] = []
var argumentLabels: [FunctionCallArgument] = []

var currentIndex = startOfScope
repeat {
Expand All @@ -2551,9 +2609,15 @@ extension Formatter {
let argumentLabelIndex = index(of: .nonSpaceOrCommentOrLinebreak, before: colonIndex),
tokens[argumentLabelIndex].isIdentifier
{
argumentLabels.append(tokens[argumentLabelIndex].string)
argumentLabels.append(FunctionCallArgument(
label: tokens[argumentLabelIndex].string,
value: tokens[colonIndex + 1 ..< endOfCurrentArgument].string
))
} else {
argumentLabels.append(nil)
argumentLabels.append(FunctionCallArgument(
label: nil,
value: tokens[endOfPreviousArgument + 1 ..< endOfCurrentArgument].string
))
}

if endOfCurrentArgument >= endOfScope {
Expand Down Expand Up @@ -2603,6 +2667,32 @@ extension Formatter {
return conformances
}

/// Removes the protocol conformance at the given index.
/// e.g. can remove `Foo` from `Type: Foo, Bar {` (becomes `Type: Bar {`).
func removeConformance(at conformanceIndex: Int) {
guard let previousToken = index(of: .nonSpaceOrCommentOrLinebreak, before: conformanceIndex),
let nextToken = index(of: .nonSpaceOrCommentOrLinebreak, after: conformanceIndex)
else { return }

// The first conformance will be preceded by a colon.
// Every conformance but the last one will be followed by a comma.
// - for example: `Type: Foo, Bar, Baaz {`
let isFirstConformance = tokens[previousToken] == .delimiter(":")
let isLastConformance = tokens[nextToken] != .delimiter(",")
let isOnlyConformance = isFirstConformance && isLastConformance

if isLastConformance || isOnlyConformance {
removeTokens(in: previousToken ... conformanceIndex)
} else {
// When changing `Foo, Bar` to just `Bar`, also remove the space between them
if token(at: nextToken + 1)?.isSpace == true {
removeTokens(in: conformanceIndex ... (nextToken + 1))
} else {
removeTokens(in: conformanceIndex ... (nextToken + 1))
}
}
}

/// The explicit `Visibility` of the `Declaration` with its keyword at the given index
func declarationVisibility(keywordIndex: Int) -> Visibility? {
// Search for a visibility keyword in the tokens before the primary keyword,
Expand Down
1 change: 1 addition & 0 deletions Sources/RuleRegistry.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ let ruleRegistry: [String: FormatRule] = [
"specifiers": .specifiers,
"strongOutlets": .strongOutlets,
"strongifiedSelf": .strongifiedSelf,
"swiftTesting": .swiftTesting,
"todos": .todos,
"trailingClosures": .trailingClosures,
"trailingCommas": .trailingCommas,
Expand Down
62 changes: 0 additions & 62 deletions Sources/Rules/RedundantEquatable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,66 +289,4 @@ extension Formatter {

return validComparedProperties
}

/// Removes the protocol conformance at the given index.
/// e.g. can remove `Foo` from `Type: Foo, Bar {` (becomes `Type: Bar {`).
func removeConformance(at conformanceIndex: Int) {
guard let previousToken = index(of: .nonSpaceOrCommentOrLinebreak, before: conformanceIndex),
let nextToken = index(of: .nonSpaceOrCommentOrLinebreak, after: conformanceIndex)
else { return }

// The first conformance will be preceded by a colon.
// Every conformance but the last one will be followed by a comma.
// - for example: `Type: Foo, Bar, Baaz {`
let isFirstConformance = tokens[previousToken] == .delimiter(":")
let isLastConformance = tokens[nextToken] != .delimiter(",")
let isOnlyConformance = isFirstConformance && isLastConformance

if isLastConformance || isOnlyConformance {
removeTokens(in: previousToken ... conformanceIndex)
} else {
// When changing `Foo, Bar` to just `Bar`, also remove the space between them
if token(at: nextToken + 1)?.isSpace == true {
removeTokens(in: conformanceIndex ... (nextToken + 1))
} else {
removeTokens(in: conformanceIndex ... (nextToken + 1))
}
}
}

/// Adds imports for the given list of modules to this file if not already present
func addImports(_ importsToAddIfNeeded: Set<String>) {
let importRanges = parseImports()
let currentImports = Set(importRanges.flatMap { $0.map(\.module) })

for importToAddIfNeeded in importsToAddIfNeeded {
guard !currentImports.contains(importToAddIfNeeded) else { continue }

let newImport: [Token] = [.keyword("import"), .space(" "), .identifier(importToAddIfNeeded)]

// If there are any existing imports, add the new import in the existing group
if let firstImportIndex = index(of: .keyword("import"), after: -1) {
insert(newImport + [linebreakToken(for: firstImportIndex)], at: firstImportIndex)
}

// Otherwise if there are no imports:
// - Make sure to insert the comment after any header comment if present
// - Include a blank line after the import
else {
let insertionIndex: Int
if let headerCommentRange = headerCommentTokenRange(), !headerCommentRange.isEmpty {
insertionIndex = headerCommentRange.upperBound
} else {
insertionIndex = 0
}

let newImportWithBlankLine = newImport + [
linebreakToken(for: insertionIndex),
linebreakToken(for: insertionIndex),
]

insert(newImportWithBlankLine, at: insertionIndex)
}
}
}
}
Loading

0 comments on commit cc74a69

Please sign in to comment.