Skip to content

Commit

Permalink
Add initial implementation of @ProvidesFixture macro (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
liamnichols authored Jun 25, 2023
1 parent 2e12c54 commit 7b75bd1
Show file tree
Hide file tree
Showing 10 changed files with 493 additions and 2 deletions.
14 changes: 14 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"pins" : [
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-syntax.git",
"state" : {
"revision" : "165fc6d22394c1168ff76ab5d951245971ef07e5",
"version" : "509.0.0-swift-DEVELOPMENT-SNAPSHOT-2023-06-05-a"
}
}
],
"version" : 2
}
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version: 5.8
// swift-tools-version: 5.6
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
Expand Down
45 changes: 45 additions & 0 deletions [email protected]
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
import CompilerPluginSupport

let package = Package(
name: "SwiftFixture",
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)],
products: [
.library(name: "SwiftFixture", targets: ["SwiftFixture"])
],
dependencies: [
.package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0-swift-5.9-DEVELOPMENT-SNAPSHOT-2023-04-25-b"),
],
targets: [
.target(
name: "SwiftFixture",
dependencies: [
.target(name: "SwiftFixtureMacros")
]
),
.testTarget(
name: "SwiftFixtureTests",
dependencies: [
.target(name: "SwiftFixture")
]
),

.macro(
name: "SwiftFixtureMacros",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
]
),
.testTarget(
name: "SwiftFixtureMacrosTests",
dependencies: [
.target(name: "SwiftFixtureMacros"),
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
]
)
]
)
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,45 @@ let user = try fixture(User.self)

Registering custom providers (i.e in the `setUp()` method) helps keep your individual test methods concise since you don't need to directly invoke potentially lengthy initializers of objects with values that you don't actually care about purely to satisfy the compiler.

Alternatively, you can add conformance to the `FixtureProviding` protocol to avoid having to register types each time.

## Macro Support

As an alternative to manually registering each of your own types for use with `Fixture`, you can also use the `@ProvideFixture` macro (in Swift 5.9 or later) to generate conformance to the `FixtureProviding` protocol as follows:

```swift
import SwiftFixture

@ProvideFixture
struct User {
let id: UUID
let name: String
let isActive: Bool
let createdAt: Date
}
```

<details>
<summary><b>Expand Macro</b></summary>

```swift
import SwiftFixture

struct User {
let id: UUID
let name: String
let isActive: Bool
let createdAt: Date
public static func provideFixture(using fixture: Fixture) throws -> Self {
Self(id: try fixture(), name: try fixture(), isActive: try fixture(), createdAt: try fixture())
}
}

extension User : FixtureProviding {}
```

</details>

## Inspiration

This library was inspired by [KFixture](https://github.com/FlexTradeUKLtd/kfixture), a Kotlin wrapper around [JFixture](https://github.com/FlexTradeUKLtd/jfixture) (inspired by [AutoFixture](AutoFixture)).
9 changes: 9 additions & 0 deletions Sources/SwiftFixture/Macros.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#if compiler(>=5.9)
/// A macro used to automatically synthesise ``FixtureProviding`` conformance for a given type.
@attached(member, names: named(provideFixture))
@attached(conformance)
public macro ProvideFixture() = #externalMacro(
module: "SwiftFixtureMacros",
type: "ProvideFixtureMacro"
)
#endif
21 changes: 21 additions & 0 deletions Sources/SwiftFixtureMacros/ExpansionError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

enum ExpansionError: Error {
case noInitializers
case tooManyInitializers
}

extension ExpansionError: CustomDebugStringConvertible {
var debugDescription: String {
switch self {
case .noInitializers:
return """
@ProvideFixture requires that at least one initializer is defined
"""
case .tooManyInitializers:
return """
@ProvideFixture is unable to disambiguate between multiple initializers
"""
}
}
}
9 changes: 9 additions & 0 deletions Sources/SwiftFixtureMacros/Plugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct Plugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
ProvideFixtureMacro.self
]
}
164 changes: 164 additions & 0 deletions Sources/SwiftFixtureMacros/ProvideFixtureMacro.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct ProvideFixtureMacro: MemberMacro, ConformanceMacro {
/// A type that describes the initializer to be called in the `FixtureProviding.provideFixture(using:)` implementation
struct InitializerContext {
let typeIdentifier: TokenSyntax
let argumentLabels: [String?]
let isThrowing: Bool
}

public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// Discover the initializer aguments
let initializer = try initializerContext(for: declaration)

// Create the provideFixutre implementation calling through to the initialiser
// public static func provideFixture(using fixture: Fixture) throws -> Self { ... }
let functionDecl = try FunctionDeclSyntax(
"public static func provideFixture(using fixture: Fixture) throws -> \(initializer.typeIdentifier)"
) {
CodeBlockItemListSyntax {

// Self(foo: try fixture(), bar: try fixture())
FunctionCallExprSyntax(callee: IdentifierExprSyntax(identifier: initializer.typeIdentifier)) {
for label in initializer.argumentLabels {

// foo: try fixture()
TupleExprElementSyntax(
label: label,
expression: TryExprSyntax(
expression: FunctionCallExprSyntax(
callee: IdentifierExprSyntax(
identifier: "fixture"
)
)
)
)
}
}
.wrapInTry(initializer.isThrowing) // try Self(...)
}
}

return [
DeclSyntax(functionDecl)
]
}

public static func expansion(
of node: AttributeSyntax,
providingConformancesOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [(TypeSyntax, GenericWhereClauseSyntax?)] {
return [
("FixtureProviding", nil)
]
}
}

// MARK: - Arguments
private extension ProvideFixtureMacro {
static func initializerContext(for declaration: some DeclGroupSyntax) throws -> InitializerContext {
// Find all initializers in the declaration
let initializers = declaration.memberBlock.members.compactMap { $0.decl.as(InitializerDeclSyntax.self) }
let typeIdentifier: TokenSyntax = "Self" // TODO: Try and figure out the actual type later?

// If there are none, and it's a struct, assume use of the memberwise init
if initializers.isEmpty, let declaration = declaration.as(StructDeclSyntax.self) {
return InitializerContext(
typeIdentifier: typeIdentifier,
argumentLabels: memberwiseInitializerArgumentLabels(for: declaration),
isThrowing: false
)
}

// Otherwise build the context from the most appropriate initializer decl
return InitializerContext(
decl: try bestInitializer(from: initializers),
typeIdentifier: typeIdentifier
)
}

private static func bestInitializer(
from initializers: [InitializerDeclSyntax]
) throws -> InitializerDeclSyntax {
if initializers.isEmpty {
throw ExpansionError.noInitializers
} else if let initializer = initializers.first, initializers.count == 1 {
return initializer
}

// If there are multiple options, either find the first initializer
// TODO: Check for the marker as a reference to disambiguate
throw ExpansionError.tooManyInitializers
}

private static func memberwiseInitializerArgumentLabels(
for declaration: StructDeclSyntax
) -> [String] {
var labels: [String] = []

for member in declaration.memberBlock.members {
guard let variable = member.decl.as(VariableDeclSyntax.self) else { continue }

// for let keywords without initializer values
if variable.bindingKeyword.tokenKind == .keyword(.let) {
for binding in variable.bindings where binding.initializer == nil {
guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self) else { continue }
labels.append(identifier.identifier.text)
}
}

// for non-computed vars
if variable.bindingKeyword.tokenKind == .keyword(.var) {
for binding in variable.bindings where binding.accessor == nil {
guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self) else { continue }
labels.append(identifier.identifier.text)
}
}
}

return labels
}
}

// MARK: - Utils
private extension FunctionCallExprSyntax {
func wrapInTry(_ wrapInTry: Bool = true) -> ExprSyntaxProtocol {
if wrapInTry {
return TryExprSyntax(expression: self)
} else {
return self
}
}
}

private extension ProvideFixtureMacro.InitializerContext {
init(decl: InitializerDeclSyntax, typeIdentifier: TokenSyntax) {
let isThrowing = decl.signature.effectSpecifiers?.throwsSpecifier != nil
let argumentLabels: [String?] = decl.signature.input.parameterList.map { parameter in
switch parameter.firstName.tokenKind {
case .identifier(let label):
return label
case .wildcard:
return nil
default:
// afaik, the external parameter label can only either be a wildcard or an identifier
// TODO: Confirm if this code path is possible
fatalError("Unexpected TokenKind \(parameter.firstName.tokenKind)")
}
}

self.init(
typeIdentifier: typeIdentifier,
argumentLabels: argumentLabels,
isThrowing: isThrowing
)
}
}
9 changes: 8 additions & 1 deletion Tests/SwiftFixture.xctestplan
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
}
],
"defaultOptions" : {

"codeCoverage" : false
},
"testTargets" : [
{
Expand All @@ -18,6 +18,13 @@
"identifier" : "SwiftFixtureTests",
"name" : "SwiftFixtureTests"
}
},
{
"target" : {
"containerPath" : "container:",
"identifier" : "SwiftFixtureMacrosTests",
"name" : "SwiftFixtureMacrosTests"
}
}
],
"version" : 1
Expand Down
Loading

0 comments on commit 7b75bd1

Please sign in to comment.