Skip to content

Commit

Permalink
Merge pull request #6 from JosephDuffy/nsobject-improvements
Browse files Browse the repository at this point in the history
NSObject API Improvements
  • Loading branch information
JosephDuffy authored Feb 7, 2024
2 parents 0e77401 + 862e6d7 commit d6efa5f
Show file tree
Hide file tree
Showing 20 changed files with 1,218 additions and 1,244 deletions.
29 changes: 15 additions & 14 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
branches:
- "**:**" # PRs from forks have a prefix with `owner:`

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
macos_tests:
name: macOS Tests (SwiftPM, Xcode ${{ matrix.xcode }})
Expand All @@ -25,9 +29,11 @@ jobs:
uses: actions/cache@v3
with:
path: .build
key: ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-deps-${{ github.workspace }}-${{ hashFiles('Package.resolved') }}
key: ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-${{ github.ref }}-${{ hashFiles('**/Package.resolved') }}
restore-keys: |
${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-deps-${{ github.workspace }}
${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-${{ github.ref }}-
${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-main-
${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-
- name: SwiftPM tests
run: swift test --enable-code-coverage
Expand Down Expand Up @@ -75,21 +81,14 @@ jobs:
- run: mint bootstrap

- name: Cache SwiftPM
uses: actions/cache@v3
with:
path: .build
key: ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-${{ hashFiles('**/Package.resolved') }}
restore-keys: |
${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-
- name: Cache DerivedData
uses: actions/cache@v3
with:
path: ~/Library/Developer/Xcode/DerivedData
key: ${{ runner.os }}-${{ matrix.platform }}-derived_data-xcode_${{ matrix.xcode }}
key: ${{ runner.os }}-xcode_${{ matrix.xcode }}-derived_data-${{ github.ref }}
restore-keys: |
${{ runner.os }}-${{ matrix.platform }}-derived_data-
${{ runner.os }}-xcode_${{ matrix.xcode }}-derived_data-main
${{ runner.os }}-xcode_${{ matrix.xcode }}-derived_data-
- name: Run Tests
run: |
Expand Down Expand Up @@ -128,9 +127,11 @@ jobs:
uses: actions/cache@v3
with:
path: .build
key: ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-deps-${{ github.workspace }}-${{ hashFiles('Package.resolved') }}
key: ${{ runner.os }}-swift_${{ matrix.swift }}-swiftpm-deps-${{ github.ref }}-${{ hashFiles('Package.resolved') }}
restore-keys: |
${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-deps-${{ github.workspace }}
${{ runner.os }}-swift_${{ matrix.swift }}-swiftpm-deps-${{ github.ref }}-
${{ runner.os }}-swift_${{ matrix.swift }}-swiftpm-deps-main-
${{ runner.os }}-swift_${{ matrix.swift }}-swiftpm-deps-
- name: swift test
run: swift test
16 changes: 16 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,29 @@ let package = Package(
.target(
name: "HashableMacro",
dependencies: [
.targetItem(
name: "HashableMacroFoundation",
condition: .when(
platforms: [.macOS, .iOS, .tvOS, .watchOS, .macCatalyst]
)
),
"HashableMacroMacros",
],
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
),
.target(
name: "HashableMacroFoundation",
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
),
.macro(
name: "HashableMacroMacros",
dependencies: [
.targetItem(
name: "HashableMacroFoundation",
condition: .when(
platforms: [.macOS, .iOS, .tvOS, .watchOS, .macCatalyst]
)
),
.product(name: "SwiftDiagnostics", package: "swift-syntax"),
.product(name: "SwiftSyntax", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
Expand Down
54 changes: 46 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
> [!WARNING]
> This package requires Swift 5.9.2, which ships with Xcode 15.1. It is possible to add this package in Xcode 15.0 and 15.0.1 but the `@Hashable` macro will not be available.
`HashableMacro` is a Swift macro for adding `Hashable` conformance. It is particularly useful when synthesised conformance is not possible, such as with classes or a struct with 1 or more non-hashable properties.
`@Hashable` is a Swift macro for adding `Hashable` conformance. It is particularly useful when synthesised conformance is not possible, such as with classes or a struct with 1 or more non-hashable properties.

The `@Hashable` macro is applied to the type that will conform to `Hashable` and the `Hashed` macro is applied to each of the properties that should contribute to the `Hashable` conformance.

```swift
import HashableMacro

/// A struct that uses the ``stringProperty`` and ``intProperty`` for `Hashable` conformance.
@Hashable
struct MyStruct {
Expand Down Expand Up @@ -52,7 +54,7 @@ struct MyStruct {

## `@Hashable` Only

If the `@Hashable` macro is added but no properties are decorated with `@Hashed` or `@NotHashed` then all properties will be used.
If the `@Hashable` macro is added but no properties are decorated with `@Hashed` or `@NotHashed` then all stored properties will be used.

```swift
/// A struct that uses the ``stringProperty`` and ``intProperty`` for `Hashable` conformance.
Expand All @@ -66,7 +68,7 @@ struct MyStruct {

// Implicitly excluded from `Hashable` conformance
var computedProperty: Bool {
intProperty > 0
intProperty > 0
}
}
```
Expand All @@ -75,20 +77,56 @@ One (fairly minor) advantage of this over adding `Hashable` conformance without

## `NSObject` Support

When a type inherits from `NSObject` it should override `hash` and `isEqual(_:)`, not `hash(into:)` and `==`. `HashableMacro` detects when it is attached to a type conforming to `NSObjectProtocol` and will provide the `hash` property and `isEqual(_:)` function instead.
When a type implements `NSObjectProtocol` (e.g. it inherits from `NSObject`) it should override `hash` and `isEqual(_:)`, not `hash(into:)` and `==`. `@Hashable` detects when it is attached to a type conforming to `NSObjectProtocol` and will provide the `hash` property and `isEqual(_:)` function instead.

`@Hashable` will also provide an `isEqual(to:)` function that takes a parameter that matches `Self`, which will also have an appropriately named Objective-C function.

```swift
import HashableMacro

@Hashable
final class Person: NSObject {
@Hashed
var name: String = ""
}

extension Person {
override var hash: Int {
var hasher = Hasher()
hasher.combine(self.name)
return hasher.finalize()
}
}

By default `HashableMacro` will incorporate `super.isEqual(_:)` and `super.hash`, unless the type is a direct subclass of `NSObject`. This behaviour can be changed with the `nsObjectSubclassBehaviour` parameter.
extension Person {
override func isEqual(_ object: Any?) -> Bool {
guard let object = object as? Person else {
return false
}
guard type(of: self) == type(of: object) else {
return false
}
return self.isEqual(to: object)
}
@objc(isEqualToPerson:)
func isEqual(to object: Person) -> Bool {
return self.name == object.name
}
}
```

## `final` `hash(into:)` Function

When the `HashableMacro` macro is added to a class the generated `hash(into:)` function is marked `final`. This is because subclasses should not overload `==`. There are many reasons why this can be a bad idea, but specifically in Swift this does not work because:
When the `@Hashable` macro is added to a class the generated `hash(into:)` function is marked `final`. This is because subclasses should not overload `==`. There are many reasons why this can be a bad idea, but specifically in Swift this does not work because:

- `!=` is not part of the `Equatable` protocol, but rather an extension on `Equatable`, causing it to always use the `==` implementation from the class that adds `Equatable` conformance
- It is possible to overload `!=` but this is still not a good idea because...
- Anything that uses generics to compare the values, for example `XCTAssertEqual`, will use the `==` implementation from the class that adds `Equatable` conformance
- It is possible to work around this by using a separate function, in a similar way to `NSObjectProtocol`, which is then called from `==`, but this requires extra decisions to be made that shouldn't be made by this library, e.g. what to do when a subclass is compared to its superclass.
- It is possible to work around this by using a separate function, in a similar way to `NSObject`, which is then called from `==`

If this is an issue for your usage you can pass `finalHashInto: false` to the macro, but it will not attempt to call `super` or use properties from the superclass.

If this is an issue for your usage you can pass `finalHashInto: false` to the macro, but it will not attempt to call `super` or use the properties annotated with `@Hashed` from the superclass.
This is not something the macro aims to solve.

## License

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#if canImport(HashableMacroFoundation)
#if canImport(ObjectiveC)
import HashableMacroFoundation

public typealias IsEqualToTypeFunctionNameGeneration = HashableMacroFoundation.IsEqualToTypeFunctionNameGeneration
#else
#warning("ObjectiveC should be importable when HashableMacroFoundation can be imported")
#endif
#endif
71 changes: 43 additions & 28 deletions Sources/HashableMacro/Macros.swift
Original file line number Diff line number Diff line change
@@ -1,50 +1,65 @@
#if canImport(ObjectiveC)
import ObjectiveC
import HashableMacroFoundation

/// A macro that adds `Hashable` conformance to the type it is attached to. The
/// `==` function and `hash(into:)` functions will use the same properties. To
/// include a property decorate it with the ``Hashed()`` macro.
/// hash generation and equality checks will use the same properties. To include
/// a property decorate it with the ``Hashed()`` macro. Alternatively the
/// ``NotHashed()`` can be used with struct properties to opt-out a subset of
/// properties, rather then opt-in.
///
/// If this is attached to a type conforming to `NSObjectProtocol` this will
/// instead override the `hash` property and `isEqual(_:)` function.
/// When attached to a struct with only hashable properties the ``Hashed()`` and
/// ``NotHashed()`` macros can be omitted and all properties will be used.
///
/// When attached to a Swift type this macro will provide the `hash(into:)`
/// function and the `==(lhs:rhs:)` function. When attached to a type conforming
/// to `NSObjectProtocol` this will produce a `hash` property, an `isEqual(_:)`
/// function, and as `isEqualToType(_:)`-style function
///
/// For types conforming to `NSObjectProtocol` another object will only compare
/// equal if it of the same class (e.g. subclasses and superclasses will never
/// compare equal) and all annotated properties are equal.
///
/// - parameter finalHashInto: When `true`, and the macro is attached to a
/// class that doesn't implement `NSObjectProtocol`, the `hash(into:)`
/// function will be marked `final`. This helps avoid a pitfall when
/// subclassing an `Equatable` class: the `==` function cannot be overridden
/// in a subclass and `==` will always use the superclass.
@attached(extension, conformances: Hashable, Equatable, NSObjectProtocol, names: named(hash), named(==), named(isEqual(_:)), named(hash))
@available(swift 5.9.2)
/// class, the `hash(into:)` function will be marked `final`. This helps avoid
/// a pitfall when subclassing an `Equatable` class: the `==` function cannot
/// be overridden in a subclass and `==` will always use the superclass.
/// - parameter isEqualToTypeFunctionName: The name to use when using the
/// `isEqual(to:)` function from Objective-C. Defaults to using the name of the
/// class the macro is attached to. This only applies to types that conform to
/// `NSObjectProtocol`.
#if compiler(>=5.9.2)
@attached(
extension,
conformances: Hashable, Equatable, NSObjectProtocol,
names: named(hash(into:)), named(==), named(hash), named(isEqual(_:)), named(isEqual(to:)), arbitrary
)
#else
@attached(extension)
#endif
public macro Hashable(
finalHashInto: Bool = true,
nsObjectSubclassBehaviour: NSObjectSubclassBehaviour = .callSuperUnlessDirectSubclass
isEqualToTypeFunctionName: IsEqualToTypeFunctionNameGeneration = .automatic
) = #externalMacro(module: "HashableMacroMacros", type: "HashableMacro")

public enum NSObjectSubclassBehaviour: Sendable {
/// Never call `super.isEqual(to:)` and do not incorporate `super.hash`.
case neverCallSuper

/// Call `super.isEqual(to:)` and incorporate `super.hash` only when the
/// type is not a direct subclass of `NSObject`.
case callSuperUnlessDirectSubclass

/// Always call `super.isEqual(to:)` and incorporate `super.hash`.
case alwaysCallSuper
}
#else
/// A macro that adds `Hashable` conformance to the type it is attached to. The
/// `==` function and `hash(into:)` functions will use the same properties. To
/// include a property decorate it with the ``Hashed()`` macro.
/// hash generation and equality checks will use the same properties. To include
/// a property decorate it with the ``Hashed()`` macro. Alternatively the
/// ``NotHashed()`` can be used with struct properties to opt-out a subset of
/// properties, rather then opt-in.
///
/// If this is attached to a type conforming to `NSObjectProtocol` this will
/// instead override the `hash` property and `isEqual(_:)` function.
/// When attached to a struct with only hashable properties the ``Hashed()`` and
/// ``NotHashed()`` macros can be omitted and all properties will be used.
///
/// - parameter finalHashInto: When `true`, and the macro is attached to a
/// class, the `hash(into:)` function will be marked `final`. This helps avoid
/// a pitfall when subclassing an `Equatable` class: the `==` function cannot
/// be overridden in a subclass and `==` will always use the superclass.
#if compiler(>=5.9.2)
@attached(extension, conformances: Hashable, Equatable, names: named(hash), named(==))
@available(swift 5.9.2)
#else
@attached(extension)
#endif
public macro Hashable(
finalHashInto: Bool = true
) = #externalMacro(module: "HashableMacroMacros", type: "HashableMacro")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#if canImport(ObjectiveC)
/// How to generate the name of the Objective-C function used to compare 2
/// instances of the same type.
public enum IsEqualToTypeFunctionNameGeneration: Sendable {
/// Use an automatically generated name for the Objective-C function, e.g.
/// for a class named `Person` this would use `isEqualToPerson:`.
case automatic

/// Use the provided name for the Objective-C function.
///
/// - parameter objectiveCName: The name of the function when used from
/// Objective-C. This should include a trailing colon.
case custom(_ objectiveCName: String)
}
#endif
7 changes: 0 additions & 7 deletions Sources/HashableMacroMacros/CustomHashablePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,9 @@ import SwiftSyntaxMacros

@main
struct HashableMacroPlugin: CompilerPlugin {
#if compiler(>=5.9.2)
let providingMacros: [Macro.Type] = [
HashableMacro.self,
HashedMacro.self,
NotHashedMacro.self,
]
#else
let providingMacros: [Macro.Type] = [
HashedMacro.self,
NotHashedMacro.self,
]
#endif
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#if canImport(SwiftSyntax510)
import SwiftDiagnostics
#else
@preconcurrency import SwiftDiagnostics
#endif

struct HashableMacroDiagnosticMessage: DiagnosticMessage, Error {
let message: String
let diagnosticID: MessageID
let severity: DiagnosticSeverity

init(id: String, message: String, severity: DiagnosticSeverity) {
self.message = message
diagnosticID = MessageID.makeHashableMacroMessageID(id: id)
self.severity = severity
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import SwiftDiagnostics

struct HashableMacroFixItMessage: FixItMessage {
let fixItID: MessageID
let message: String

init(id: String, message: String) {
fixItID = MessageID.makeHashableMacroMessageID(id: id)
self.message = message
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import SwiftDiagnostics

extension MessageID {
static func makeHashableMacroMessageID(id: String) -> MessageID {
MessageID(domain: "uk.josephduffy.HashableMacro", id: id)
}
}
Loading

0 comments on commit d6efa5f

Please sign in to comment.