Skip to content

Commit

Permalink
Merge pull request #10 from JosephDuffy/api-improvements
Browse files Browse the repository at this point in the history
API Improvements
  • Loading branch information
JosephDuffy committed Feb 12, 2024
2 parents b17f467 + 5eb9994 commit 606442d
Show file tree
Hide file tree
Showing 6 changed files with 773 additions and 41 deletions.
20 changes: 15 additions & 5 deletions Sources/HashableMacro/Macros.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,13 @@ import HashableMacroFoundation
/// 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`.
/// `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`.
/// - parameter allowEmptyImplementation: When `nil` (default) and there are no
/// properties that contribute to the `Hashable` conformance the macro will
/// produce a warning. When `true` this warning is suppressed. When `false`
/// the warning will be elevated to an error.
#if compiler(>=5.9.2)
@attached(
extension,
Expand All @@ -39,7 +43,8 @@ import HashableMacroFoundation
#endif
public macro Hashable(
finalHashInto: Bool = true,
isEqualToTypeFunctionName: IsEqualToTypeFunctionNameGeneration = .automatic
isEqualToTypeFunctionName: IsEqualToTypeFunctionNameGeneration = .automatic,
allowEmptyImplementation: Bool? = nil
) = #externalMacro(module: "HashableMacroMacros", type: "HashableMacro")
#else
/// A macro that adds `Hashable` conformance to the type it is attached to. The
Expand All @@ -55,13 +60,18 @@ public macro Hashable(
/// 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 allowEmptyImplementation: When `nil` (default) and there are no
/// properties that contribute to the `Hashable` conformance the macro will
/// produce a warning. When `true` this warning is suppressed. When `false`
/// the warning will be elevated to an error.
#if compiler(>=5.9.2)
@attached(extension, conformances: Hashable, Equatable, names: named(hash), named(==))
#else
@attached(extension)
#endif
public macro Hashable(
finalHashInto: Bool = true
finalHashInto: Bool = true,
allowEmptyImplementation: Bool? = nil
) = #externalMacro(module: "HashableMacroMacros", type: "HashableMacro")
#endif

Expand Down
106 changes: 106 additions & 0 deletions Sources/HashableMacroMacros/LabeledExprListSyntax+extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import SwiftSyntax

extension LabeledExprListSyntax {
mutating func appendAndFixTrailingComma(expression: LabeledExprSyntax) {
if let lastIndex = indices.last {
// Attempt to keep some formatting, e.g. if each parameter
// placed on a new line.
let trailingTrivia = self[lastIndex].trailingComma?.trailingTrivia ?? .space
self[lastIndex].trailingComma = .commaToken(trailingTrivia: trailingTrivia)
}

append(expression)
}

mutating func addOrUpdateArgument(
label: String,
expression: some ExprSyntaxProtocol,
allArguments: LabeledExprListSyntax
) {
let label = TokenSyntax.identifier(label)

var argumentExpression = LabeledExprSyntax(
label: label,
colon: .colonToken(trailingTrivia: .space),
expression: expression
)

guard !isEmpty else {
insert(
argumentExpression,
at: startIndex
)
return
}

if let existingArgumentIndex = firstIndex(where: { $0.label?.text == label.text }) {
self[existingArgumentIndex].expression = ExprSyntax(expression)
} else if let indexInAllArguments = allArguments.firstIndex(where: { $0.label?.text == label.text }) {
let possibleIndicesInAllArguments = allArguments.indices.prefix(while: { $0 != indexInAllArguments })
// Work backwards until we find the index in `self` for the first
// parameter that comes before this new one.
for priorIndex in possibleIndicesInAllArguments.reversed() {
let defaultArgument = allArguments[priorIndex]

if let indexBeforeIndexToInsertAt = firstIndex(where: { $0.label?.text == defaultArgument.label?.text }) {
let indexToInsertAt = index(after: indexBeforeIndexToInsertAt)
if indexToInsertAt != endIndex {
argumentExpression.trailingComma = .commaToken(trailingTrivia: .space)
} else {
self[indexBeforeIndexToInsertAt].trailingComma = .commaToken(trailingTrivia: .space)
}
insert(
argumentExpression,
at: indexToInsertAt
)
return
}
}

// Fallback to adding it at the start
argumentExpression.trailingComma = .commaToken(trailingTrivia: .space)
insert(
argumentExpression,
at: startIndex
)
} else {
// Fallback to appending when the argument is not known.
appendAndFixTrailingComma(
expression: argumentExpression
)
}
}
}

extension LabeledExprListSyntax {
static var allHashableArguments: LabeledExprListSyntax {
[
LabeledExprSyntax(
label: "finalHashInto",
expression: BooleanLiteralExprSyntax(booleanLiteral: true)
),
LabeledExprSyntax(
label: "isEqualToTypeFunctionName",
expression: MemberAccessExprSyntax(
declName: DeclReferenceExprSyntax(
baseName: .identifier("automatic")
)
)
),
LabeledExprSyntax(
label: "allowEmptyImplementation",
expression: BooleanLiteralExprSyntax(booleanLiteral: false)
),
LabeledExprSyntax(
label: "_disableNSObjectSubclassSupport",
expression: BooleanLiteralExprSyntax(booleanLiteral: false)
),
]
}
mutating func addOrUpdateHashableArgument(
label: String,
expression: some ExprSyntaxProtocol
) {
addOrUpdateArgument(label: label, expression: expression, allArguments: .allHashableArguments)
}
}
120 changes: 115 additions & 5 deletions Sources/HashableMacroMacros/Macros/HashableMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ public struct HashableMacro: ExtensionMacro {
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [ExtensionDeclSyntax] {
guard !declaration.is(EnumDeclSyntax.self) else {
throw HashableMacroDiagnosticMessage(
id: "enum-not-supported",
message: "'Hashable' is not currently supported on enums.",
severity: .error
)
}

// The macro declares that it can add `NSObjectProtocol`, but this is
// used to check whether the compiler asks for it to be added. If the
// macro is asked to add `NSObjectProtocol` conformance then we know
Expand Down Expand Up @@ -53,6 +61,37 @@ public struct HashableMacro: ExtensionMacro {
}
}

var allowEmptyImplementation: Bool?

if let arguments = node.arguments?.as(LabeledExprListSyntax.self) {
for argument in arguments {
switch argument.label?.trimmed.text {
case "allowEmptyImplementation":
guard let expression = argument.expression.as(BooleanLiteralExprSyntax.self) else { continue }
switch expression.literal.tokenKind {
case .keyword(.true):
allowEmptyImplementation = true
case .keyword(.false):
allowEmptyImplementation = false
default:
break
}
#if canImport(ObjectiveC) && DEBUG
case "_disableNSObjectSubclassSupport":
guard let expression = argument.expression.as(BooleanLiteralExprSyntax.self) else { continue }
switch expression.literal.tokenKind {
case .keyword(.true):
isNSObjectSubclass = false
default:
break
}
#endif
default:
break
}
}
}

let properties = declaration.memberBlock.members.compactMap({ $0.decl.as(VariableDeclSyntax.self) })
var explicitlyHashedProperties: [TokenSyntax] = []
var undecoratedProperties: [TokenSyntax] = []
Expand Down Expand Up @@ -133,7 +172,78 @@ public struct HashableMacro: ExtensionMacro {
}
}

let propertiesToHash = !explicitlyHashedProperties.isEmpty ? explicitlyHashedProperties : undecoratedProperties
let propertiesToHash: [TokenSyntax]

if !explicitlyHashedProperties.isEmpty {
propertiesToHash = explicitlyHashedProperties
} else if declaration.is(StructDeclSyntax.self) {
propertiesToHash = undecoratedProperties
} else if declaration.is(ClassDeclSyntax.self) {
// We can't know if properties are added in a superclass, plus Swift
// itself does not support this.
throw HashableMacroDiagnosticMessage(
id: "synthesised-properties-not-supported-class",
message: "No properties marked with '@Hashed' were found. Synthesising Hashable conformance is not supported for classes.",
severity: .error
)
} else if declaration.is(ActorDeclSyntax.self) {
// Swift itself does not support this, probably for a good reason.
throw HashableMacroDiagnosticMessage(
id: "synthesised-properties-not-supported-actor",
message: "No properties marked with '@Hashed' were found. Synthesising Hashable conformance is not supported for actors.",
severity: .error
)
} else {
throw HashableMacroDiagnosticMessage(
id: "synthesised-properties-not-supported-unknown",
message: "No properties marked with '@Hashed' were found. Synthesising Hashable conformance is not supported for this type.",
severity: .error
)
}

if propertiesToHash.isEmpty {
switch allowEmptyImplementation {
case .some(true):
break
case .some(false):
throw HashableMacroDiagnosticMessage(
id: "no-properties-to-hash-disallowed",
message: "No hashable properties were found and 'allowEmptyImplementation' is 'false'.",
severity: .error
)
case nil:
var arguments = node.arguments?.as(LabeledExprListSyntax.self) ?? LabeledExprListSyntax()
arguments.addOrUpdateHashableArgument(
label: "allowEmptyImplementation",
expression: BooleanLiteralExprSyntax(booleanLiteral: true)
)
var fixedNode = node
fixedNode.arguments = .argumentList(arguments)
fixedNode.leftParen = .leftParenToken()
fixedNode.rightParen = .rightParenToken()
let diagnostic = Diagnostic(
node: node,
message: HashableMacroDiagnosticMessage(
id: "no-properties-to-hash",
message: "No hashable properties were found. All instances will be equal to each other.",
severity: .warning
),
fixIt: FixIt(
message: HashableMacroFixItMessage(
id: "redundant-not-hashed",
message: "Add 'allowEmptyImplementation: true' to silence this warning."
),
changes: [
FixIt.Change.replace(
oldNode: Syntax(node),
newNode: Syntax(fixedNode)
)
]
)
)
context.diagnose(diagnostic)
}
}

#if canImport(ObjectiveC)
#if DEBUG
Expand All @@ -160,7 +270,7 @@ public struct HashableMacro: ExtensionMacro {
}
#endif
if isNSObjectSubclass {
guard let classDeclaration = declaration as? ClassDeclSyntax else {
guard let classDeclaration = declaration.as(ClassDeclSyntax.self) else {
throw HashableMacroDiagnosticMessage(
id: "nsobject-subclass-not-class",
message: "This type conforms to 'NSObjectProtocol' but is not a class",
Expand All @@ -181,7 +291,7 @@ public struct HashableMacro: ExtensionMacro {
default:
throw HashableMacroDiagnosticMessage(
id: "unknown-isEqualToTypeFunctionName-name",
message: "'\(expression.declName.baseName)' is not a known value for `IsEqualToTypeFunctionNameGeneration`",
message: "'\(expression.declName.baseName)' is not a known value for `IsEqualToTypeFunctionNameGeneration`.",
severity: .error
)
}
Expand All @@ -198,7 +308,7 @@ public struct HashableMacro: ExtensionMacro {
guard functionExpression.arguments.count == 1 else {
throw HashableMacroDiagnosticMessage(
id: "invalid-isEqualToTypeFunctionName-argument",
message: "Only 1 argument is supported for 'custom'",
message: "Only 1 argument is supported for 'custom'.",
severity: .error
)
}
Expand All @@ -207,7 +317,7 @@ public struct HashableMacro: ExtensionMacro {
guard let stringExpression = nameArgument.expression.as(StringLiteralExprSyntax.self) else {
throw HashableMacroDiagnosticMessage(
id: "invalid-isEqualToTypeFunctionName-custom-argument",
message: "Only option for 'custom' must be a string",
message: "Only option for 'custom' must be a string.",
severity: .error
)
}
Expand Down
18 changes: 14 additions & 4 deletions Tests/HashableMacroTests/HashableMacroAPITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,17 @@ public class HashableClassWithNonFinalHashInto: Hashable {
/// A type that explicitly conforms to `Hashable`; the macro should not try to
/// add conformance (but it should still add the implementation required).
@Hashable
public class TypeExplicitlyConformingToHashable: Hashable {}
public struct TypeExplicitlyConformingToHashable: Hashable {
public var property: String?
}

/// A type that explicitly conforms to `Equatable`; the macro should not try to
/// add conformance for `Equatable`, but it should still add conformance for
/// `Hashable` and the implementations required.
@Hashable
public struct TypeExplicitlyConformingToEquatable: Equatable {
public var property: String?
}

/// A type that includes multiple properties declared on the same line.
///
Expand Down Expand Up @@ -215,12 +225,12 @@ struct CustomEqualityStruct {
var hashedProperty: String = ""
}

@Hashable(allowEmptyImplementation: true)
struct StructWithoutExtraProperties {}

#if canImport(ObjectiveC)
import ObjectiveC

@Hashable
class NSObjectSubclassWithoutExtraProperties: NSObject {}

@Hashable
class NSObjectSubclass: NSObject {
@Hashed
Expand Down
Loading

0 comments on commit 606442d

Please sign in to comment.