Skip to content

Commit

Permalink
Merge pull request #3 from JosephDuffy/rename-macro-to-hashable
Browse files Browse the repository at this point in the history
Rename project to HashableMacro, CustomHashable → Hashable, HashableKey → Hashed
  • Loading branch information
JosephDuffy authored Jan 31, 2024
2 parents 89bdb34 + 9fa6311 commit 4359efc
Show file tree
Hide file tree
Showing 13 changed files with 1,005 additions and 332 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ jobs:
- name: Run Tests
run: |
set -o pipefail
mint run xcutils test ${{ matrix.platform }} --scheme CustomHashable --enable-code-coverage | $(mint which xcbeautify)
mint run xcutils test ${{ matrix.platform }} --scheme HashableMacro --enable-code-coverage | $(mint which xcbeautify)
- name: Convert coverage for Codecov
id: convert-coverage
Expand Down
18 changes: 18 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
{
"pins" : [
{
"identity" : "swift-macro-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-macro-testing.git",
"state" : {
"branch" : "main",
"revision" : "15916c0c328339f54c15d616465d79700e3f7de8"
}
},
{
"identity" : "swift-snapshot-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
"state" : {
"revision" : "8e68404f641300bfd0e37d478683bb275926760c",
"version" : "1.15.2"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
Expand Down
23 changes: 14 additions & 9 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import CompilerPluginSupport
import PackageDescription

let package = Package(
name: "CustomHashable",
name: "HashableMacro",
platforms: [
.macOS(.v10_15),
.iOS(.v13),
Expand All @@ -13,26 +13,30 @@ let package = Package(
],
products: [
.library(
name: "CustomHashable",
targets: ["CustomHashable"]
name: "HashableMacro",
targets: ["HashableMacro"]
),
],
dependencies: [
.package(
url: "https://github.com/apple/swift-syntax.git",
from: "509.1.0"
),
.package(
url: "https://github.com/pointfreeco/swift-macro-testing.git",
branch: "main" // Needed to test diagnostics. 0.2.3 or higher should be ok.
),
],
targets: [
.target(
name: "CustomHashable",
name: "HashableMacro",
dependencies: [
"CustomHashableMacros",
"HashableMacroMacros",
],
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
),
.macro(
name: "CustomHashableMacros",
name: "HashableMacroMacros",
dependencies: [
.product(name: "SwiftDiagnostics", package: "swift-syntax"),
.product(name: "SwiftSyntax", package: "swift-syntax"),
Expand All @@ -42,10 +46,11 @@ let package = Package(
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
),
.testTarget(
name: "CustomHashableTests",
name: "HashableMacroTests",
dependencies: [
"CustomHashable",
"CustomHashableMacros", // Required for tests to compile on Swift < 5.9.2
"HashableMacro",
"HashableMacroMacros",
.product(name: "MacroTesting", package: "swift-macro-testing"),
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
],
Expand Down
69 changes: 57 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
# CustomHashable
# HashableMacro

> [!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 `@CustomHashable` macro will not be available.
> 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.
`CustomHashable` 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.
`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.

The `CustomHashable` macro is applied to the type that will conform to `Hashable` and the `HashableKey` macro is applied to each of the properties that should contribute to the `Hashable` conformance.
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
/// A struct that uses the ``stringProperty`` and ``intProperty`` for the `Hashable` conformance.
@CustomHashable
/// A struct that uses the ``stringProperty`` and ``intProperty`` for `Hashable` conformance.
@Hashable
struct MyStruct {
// Any property that is hashable is supported.
@HashableKey
@Hashed
let stringProperty: String

// Works on private properties, too.
@HashableKey
@Hashed
private let intProperty: Int

// Non-decorated properties are ignored
Expand All @@ -28,22 +28,67 @@ All decorated properties are included in both the `==` and `hash(into:)` impleme

> Two instances that are equal must feed the same values to `Hasher` in `hash(into:)`, in the same order.
## `@NotHashed` Macro

The `@NotHashed` macro can be applied to properties that _should not_ be included in the `Hashable` conformance. If this macro is used to decorate a property the `@Hashed` macro should not be used to decorate a property in the same type.

This can be useful for types that have a smaller number of non-hashable properties than hashable properties.

```swift
/// A struct that uses the ``stringProperty`` and ``intProperty`` for `Hashable` conformance.
@Hashable
struct MyStruct {
// Implicitly used for `Hashable` conformance
let stringProperty: String

// Implicitly used for `Hashable` conformance
private let intProperty: Int

// Explicitly excluded from `Hashable` conformance
@NotHashed
let notHashableType: NotHashableType
}
```

## `@Hashable` Only

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

```swift
/// A struct that uses the ``stringProperty`` and ``intProperty`` for `Hashable` conformance.
@Hashable
struct MyStruct {
// Implicitly used for `Hashable` conformance
let stringProperty: String

// Implicitly used for `Hashable` conformance
private let intProperty: Int

// Implicitly excluded from `Hashable` conformance
var computedProperty: Bool {
intProperty > 0
}
}
```

One (fairly minor) advantage of this over adding `Hashable` conformance without the macro is that you can see the code being produce via Right Click → Expand Macro.

## `NSObject` Support

When a type inherits from `NSObject` it should override `hash` and `isEqual(_:)`, not `hash(into:)` and `==`. `CustomHashable` detects when it is attached to a type conforming to `NSObjectProtocol` and will provide the `hash` property and `isEqual(_:)` function instead.
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.

By default `CustomHashable` 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.
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.

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

When the `CustomHashable` 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 `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:

- `!=` 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.

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 `@HashableKey` 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.

## License

Expand Down
14 changes: 0 additions & 14 deletions Sources/CustomHashableMacros/HashableKey.swift

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import ObjectiveC

/// 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 ``HashableKey()`` macro.
/// include a property decorate it with the ``Hashed()`` macro.
///
/// If this is attached to a type conforming to `NSObjectProtocol` this will
/// instead override the `hash` property and `isEqual(_:)` function.
Expand All @@ -15,10 +15,10 @@ import ObjectiveC
/// 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)
public macro CustomHashable(
public macro Hashable(
finalHashInto: Bool = true,
nsObjectSubclassBehaviour: NSObjectSubclassBehaviour = .callSuperUnlessDirectSubclass
) = #externalMacro(module: "CustomHashableMacros", type: "CustomHashable")
) = #externalMacro(module: "HashableMacroMacros", type: "HashableMacro")

public enum NSObjectSubclassBehaviour: Sendable {
/// Never call `super.isEqual(to:)` and do not incorporate `super.hash`.
Expand All @@ -34,7 +34,7 @@ public enum NSObjectSubclassBehaviour: Sendable {
#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 ``HashableKey()`` macro.
/// include a property decorate it with the ``Hashed()`` macro.
///
/// If this is attached to a type conforming to `NSObjectProtocol` this will
/// instead override the `hash` property and `isEqual(_:)` function.
Expand All @@ -45,10 +45,17 @@ public enum NSObjectSubclassBehaviour: Sendable {
/// be overridden in a subclass and `==` will always use the superclass.
@attached(extension, conformances: Hashable, Equatable, names: named(hash), named(==))
@available(swift 5.9.2)
public macro CustomHashable(
public macro Hashable(
finalHashInto: Bool = true
) = #externalMacro(module: "CustomHashableMacros", type: "CustomHashable")
) = #externalMacro(module: "HashableMacroMacros", type: "HashableMacro")
#endif

/// A marker macro that should be attached to all properties of a type that are
/// included in the `Hashable` implementation.
@attached(peer)
public macro HashableKey() = #externalMacro(module: "CustomHashableMacros", type: "HashableKey")
public macro Hashed() = #externalMacro(module: "HashableMacroMacros", type: "HashedMacro")

/// A marker macro that should be attached to all properties of a type that are
/// excluded in the `Hashable` implementation.
@attached(peer)
public macro NotHashed() = #externalMacro(module: "HashableMacroMacros", type: "NotHashedMacro")
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import SwiftSyntaxBuilder
import SwiftSyntaxMacros

@main
struct CustomHashablePlugin: CompilerPlugin {
struct HashableMacroPlugin: CompilerPlugin {
#if compiler(>=5.9.2)
let providingMacros: [Macro.Type] = [
CustomHashable.self,
HashableKey.self,
HashableMacro.self,
HashedMacro.self,
NotHashedMacro.self,
]
#else
let providingMacros: [Macro.Type] = [
HashableKey.self,
HashedMacro.self,
NotHashedMacro.self,
]
#endif
}
Loading

0 comments on commit 4359efc

Please sign in to comment.