Skip to content

Commit 419dce5

Browse files
authored
Add FXIOS-14673 [Technical Debt] [Redux] Add CopyWithUpdates macro to more cleanly copy redux states with simple property changes (#31717)
* Add the CopyWithChangesMacro Swift package to the project hierarchy. * Modified CopyWithChangesMacro code to suit our purposes, adding unit tests and renaming to CopyWithUpdatesMacro. * Import the macro into Client via Project Build Settings > General > Frameworks, Libraries, and Embedded Content. * Update swiftlint YAML to ignore all Package files instead of each Package.swift individually. * Trivial example of cleaning up a simple Redux state (SearchEngineSelectionState) using the new copyWith macro. * Update licenses to include CopyWithChanges acknowledgements.
1 parent 830073f commit 419dce5

File tree

16 files changed

+2652
-136
lines changed

16 files changed

+2652
-136
lines changed

.swiftlint.yml

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ excluded: # paths to ignore during linting. Takes precedence over `included`.
131131
# Firefox specific
132132
- build/
133133
- .build/
134+
- "**/.build"
134135
- firefox-ios/Client/Assets/Search/get_supported_locales.swift
135136
- firefox-ios/Client/Generated
136137
- firefox-ios/fastlane/
@@ -146,14 +147,10 @@ excluded: # paths to ignore during linting. Takes precedence over `included`.
146147
- firefox-ios/firefox-ios-tests/Tests/UITests/
147148
- l10n-screenshots-dd/
148149
- DerivedData/
149-
# Package.swift files need a custom header for swift-tools-version
150-
# so must be excluded due to file_header rule
151-
- firefox-ios/Package.swift
152-
- BrowserKit/Package.swift
150+
# Package.swift files need a custom header for swift-tools-version so must be excluded due to file_header rule. Ignore at all levels.
151+
- "**/Package.swift"
153152
- BrowserKit/Sources/Shared/Deferred/
154153
- BrowserKit/.build/
155-
- firefox-ios/Client/ContentBlocker/ContentBlockerGenerator/Package.swift
156-
- Package.swift
157154
- firefox-ios/Build/Intermediates.noindex/Client.build/Fennec-iphoneos/WidgetKitExtension.build/DerivedSources/IntentDefinitionGenerated/WidgetIntents/*
158155
# Focus specific
159156
- focus-ios/Blockzilla/Generated
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"configurations" : [
3+
{
4+
"id" : "40C422E3-9DA1-429B-AF8F-A94DD239F89A",
5+
"name" : "Test Scheme Action",
6+
"options" : {
7+
8+
}
9+
}
10+
],
11+
"defaultOptions" : {
12+
"codeCoverage" : false,
13+
"performanceAntipatternCheckerEnabled" : true
14+
},
15+
"testTargets" : [
16+
{
17+
"target" : {
18+
"containerPath" : "container:",
19+
"identifier" : "CopyWithUpdatesTests",
20+
"name" : "CopyWithUpdatesTests"
21+
}
22+
}
23+
],
24+
"version" : 1
25+
}

CopyWithUpdatesMacro/NOTICE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Forked Jan 2026 from Antonio Pedro Marques (https://github.com/entonio/CopyWithChanges) and customized for Firefox for iOS.

CopyWithUpdatesMacro/Package.resolved

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

CopyWithUpdatesMacro/Package.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// swift-tools-version: 6.2
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
import CompilerPluginSupport
6+
7+
let package = Package(
8+
name: "CopyWithUpdatesMacro",
9+
platforms: [.macOS(.v10_15), .iOS(.v15), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)],
10+
products: [
11+
.library(
12+
name: "CopyWithUpdates",
13+
targets: ["CopyWithUpdates"]
14+
),
15+
.executable(
16+
name: "CopyWithUpdatesClient",
17+
targets: ["CopyWithUpdatesClient"]
18+
),
19+
],
20+
dependencies: [
21+
.package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0"),
22+
],
23+
targets: [
24+
.macro(
25+
name: "CopyWithUpdatesMacros",
26+
dependencies: [
27+
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
28+
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
29+
]
30+
),
31+
.target(name: "CopyWithUpdates", dependencies: ["CopyWithUpdatesMacros"]),
32+
.executableTarget(name: "CopyWithUpdatesClient", dependencies: ["CopyWithUpdates"]),
33+
.testTarget(
34+
name: "CopyWithUpdatesTests",
35+
dependencies: [
36+
"CopyWithUpdatesMacros",
37+
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
38+
]
39+
),
40+
]
41+
)

CopyWithUpdatesMacro/README.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# CopyWithUpdates
2+
3+
Allows copying a Swift class or struct while changing arbitrary fields.
4+
5+
## Overview
6+
7+
The `@CopyWithUpdates` macro can be applied to a struct or a class to generate a function that takes facultative arguments, one for each of the target's fields, and returns a new instance that is a copy of the original, except for the provided arguments. It supports optionals.
8+
9+
## Rationale
10+
11+
Every so often, there is a need for a copy of a struct, but with differences in one or more fields. The correct way to do it is to invoke `init` with the intended values for each field, but that's unwieldy. What often happens is that the fields that have to be changed are made mutable, an assignment copy is made, and the change is made after the copy:
12+
13+
```swift
14+
// original
15+
let s1 = LargeStruct(a: 5, b: 2, c: 3, d: 7, e: 4, f: 1, g: 6)
16+
17+
// cumbersome copy changing b to 2
18+
let s2 = LargeStruct(a: s1.a, b: 2, c: s1.c, d: s1.d, e: s1.e, f: s1.f, g: s1.g)
19+
20+
// reasonably simple copy + change, but requires that s3 AND b be mutable even if neither is changed outside this context
21+
var s3 = s1
22+
s3.b = 2
23+
```
24+
25+
Making the instance mutable is a small trade-off, that can even be solved by using some variant of an initialization block. However, making the **fields** mutable just for the sake of convenient initialization can be seen as a big anti-pattern.
26+
27+
To avoid this need to make the fields mutable, `@CopyWithUpdates` provides the boilerplate needed in the form of `func with(...)`, that handles the boilerplate of calling `init` with both the changed and the kept values:
28+
29+
```swift
30+
// the solution proposed here
31+
let s4 = s1.copyWithUpdates(b: 2)
32+
33+
// the same, but changing two fields
34+
let s5 = s1.copyWithUpdates(c: 4, g: 7)
35+
36+
// will change f to nil (if field f is defined as Optional)
37+
let s6 = s1.copyWithUpdates(f: nil)
38+
```
39+
40+
The complete behavior is:
41+
- providing a value for any field will use that value for the field;
42+
- providing `nil` for a field that is non-optional is the same as not including that field in the call, i.e. the value from the original struct will be used;
43+
- providing `nil` for an optional field will make that field `nil`;
44+
- providing `.some(nil)` for an optional field is the same as not including that field in the call, i.e. the value from the original struct will be used.
45+
46+
The macro can also apply to classes. In either classes or structs, there will have to be a memberwise `init`. In structs this is automatically synthesized by the compiler unless a custom init is defined in the struct declaration. It can also be generated via Xcode's autocompletion features.
47+
48+
## Usage
49+
50+
Annotate the target type with the `@CopyWithUpdates` macro to generate `func with(...) -> Self`:
51+
52+
```swift
53+
import CopyWithUpdates
54+
55+
@CopyWithUpdates
56+
struct Report {
57+
let venue: String
58+
let sponsor: String?
59+
let drinks: [String]
60+
let complexStructure: [Date: [(String, Int)]]
61+
let characters: [String]?
62+
let budget: Double
63+
}
64+
```
65+
66+
The code after expansion:
67+
68+
```swift
69+
struct Report {
70+
let venue: String
71+
let sponsor: String?
72+
let drinks: [String]
73+
let complexStructure: [Date: [(String, Int)]]
74+
let characters: [String]?
75+
let budget: Double
76+
77+
public func copyWithUpdates(venue: String? = nil, sponsor: String?? = .some(nil), drinks: [String]? = nil, complexStructure: [Date: [(String, Int)]]? = nil, characters: [String]?? = .some(nil), budget: Double? = nil) -> Self {
78+
Self (
79+
venue: venue ?? self.venue,
80+
sponsor: sponsor == .none ? nil : self.sponsor,
81+
drinks: drinks ?? self.drinks,
82+
complexStructure: complexStructure ?? self.complexStructure,
83+
characters: characters == .none ? nil : self.characters,
84+
budget: budget ?? self.budget
85+
)
86+
}
87+
}
88+
```
89+
90+
## License
91+
92+
All the files in this package are copyright of the package contributors mentioned in the [NOTICE](NOTICE) file and licensed under the [Apache 2.0 License](http://www.apache.org/licenses/LICENSE-2.0), which is permissive for business use.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/
4+
5+
@attached(member, names: named(copyWithUpdates))
6+
public macro CopyWithUpdates() = #externalMacro(module: "CopyWithUpdatesMacros", type: "CopyWithUpdatesMacro")
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/
4+
5+
import Foundation
6+
import CopyWithUpdates
7+
8+
// This file contains a simple executable demo of the CopyWithUpdates macro.
9+
@CopyWithUpdates
10+
struct Report {
11+
let venue: String
12+
let sponsor: String?
13+
let drinks: [String]
14+
let complexStructure: [Date: [(String, Int)]]
15+
let characters: [String]?
16+
let budget: Double
17+
}
18+
19+
let r1 = Report(
20+
venue: "Grapefruit",
21+
sponsor: "Oumaouma",
22+
drinks: ["soda", "tea"],
23+
complexStructure: [Date(): [("Blunt!", 200)]],
24+
characters: [],
25+
budget: 12_345_678.9
26+
)
27+
28+
let r2 = r1.copyWithUpdates(
29+
characters: ["Jane Doe"]
30+
)
31+
32+
let r3 = r1.copyWithUpdates(
33+
sponsor: nil,
34+
complexStructure: [:],
35+
budget: 0
36+
)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/
4+
5+
import SwiftCompilerPlugin
6+
import SwiftSyntax
7+
import SwiftSyntaxBuilder
8+
import SwiftSyntaxMacros
9+
import SwiftDiagnostics
10+
11+
/// Implementation of the `CopyWithUpdates` macro.
12+
public struct CopyWithUpdatesMacro: MemberMacro {
13+
public static func expansion(
14+
of attribute: AttributeSyntax,
15+
providingMembersOf declaration: some DeclGroupSyntax,
16+
in context: some MacroExpansionContext
17+
) throws -> [DeclSyntax] {
18+
guard let members = (declaration.as(StructDeclSyntax.self)?.memberBlock.members
19+
?? declaration.as(ClassDeclSyntax.self)?.memberBlock.members) else {
20+
context.diagnose(Diagnostic(
21+
node: attribute,
22+
message: CopyWithUpdatesDiagnostic.unsupportedTarget
23+
))
24+
25+
return []
26+
}
27+
28+
let variableDecls = members.compactMap { member -> VariableDeclSyntax? in
29+
guard let variableDecl = member.decl.as(VariableDeclSyntax.self) else {
30+
return nil
31+
}
32+
33+
// Check if the 'static' modifier is present; we don't want these properties copied into the generated copyWith
34+
// method.
35+
let isStatic = variableDecl.modifiers.contains { modifier in
36+
modifier.name.tokenKind == .keyword(.static)
37+
}
38+
39+
return isStatic ? nil : variableDecl
40+
}
41+
42+
let bindings = variableDecls.flatMap { $0.bindings }
43+
44+
// Based on the each property's name and type, construct the copyWith function arguments and internal assignments
45+
var arguments: [String] = []
46+
var assignments: [String] = []
47+
48+
for binding in bindings {
49+
guard let propertyName = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text,
50+
let propertyType = binding.typeAnnotation?.as(TypeAnnotationSyntax.self)?.type else {
51+
context.diagnose(Diagnostic(
52+
node: attribute,
53+
message: CopyWithUpdatesDiagnostic.unsupportedBinding(binding)
54+
))
55+
56+
continue
57+
}
58+
59+
if propertyType.is(OptionalTypeSyntax.self) {
60+
/// Optionals are treated as double optionals (`??`)
61+
arguments.append("\(propertyName): \(propertyType)? = .some(nil)")
62+
63+
/// We treat a top-level `nil` argument semantically as setting the property to `nil`, instead of passing
64+
/// `.some(nil)`, which would feel odd at call sites.
65+
assignments.append("\(propertyName): \(propertyName).map { $0 ?? self.\(propertyName) } ?? nil")
66+
} else {
67+
arguments.append("\(propertyName): \(propertyType)? = nil")
68+
assignments.append("\(propertyName): \(propertyName) ?? self.\(propertyName)")
69+
}
70+
}
71+
72+
// Construct the return statement with proper syntax
73+
let copyWithFunction = try FunctionDeclSyntax("public func copyWithUpdates(\(raw: arguments.joined(separator: ", "))) -> Self") {
74+
return """
75+
return Self(
76+
\(raw: assignments.joined(separator: ",\n"))
77+
)
78+
"""
79+
}
80+
81+
return [
82+
DeclSyntax(copyWithFunction),
83+
]
84+
}
85+
86+
enum CopyWithUpdatesDiagnostic: DiagnosticMessage {
87+
case unsupportedTarget
88+
case unsupportedBinding(PatternBindingSyntax)
89+
90+
var severity: DiagnosticSeverity {
91+
switch self {
92+
case .unsupportedTarget: .error
93+
case .unsupportedBinding: .warning
94+
}
95+
}
96+
97+
var message: String {
98+
switch self {
99+
case .unsupportedTarget:
100+
"'@CopyWithUpdates' can only be applied to a struct or a class"
101+
case .unsupportedBinding(let binding):
102+
"'@CopyWithUpdates' cannot copy field '\(binding)'"
103+
}
104+
}
105+
106+
var diagnosticID: MessageID {
107+
MessageID(domain: "CopyWithUpdatesMacros", id: String(describing: self))
108+
}
109+
}
110+
}
111+
112+
@main
113+
struct CopyWithUpdatesPlugin: CompilerPlugin {
114+
let providingMacros: [Macro.Type] = [
115+
CopyWithUpdatesMacro.self,
116+
]
117+
}

0 commit comments

Comments
 (0)