Skip to content

Commit 64b53e0

Browse files
committed
Initial commit
0 parents  commit 64b53e0

32 files changed

Lines changed: 1467 additions & 0 deletions

.claude/settings.local.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"enableAllProjectMcpServers": false
3+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: Documentation
2+
3+
on:
4+
push:
5+
tags:
6+
- '*'
7+
workflow_dispatch:
8+
9+
# Set permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages.
10+
permissions:
11+
contents: read
12+
id-token: write
13+
pages: write
14+
15+
# Allow one concurrent deployment. Do not cancel in-flight deployments because we don't want assets to be in a
16+
# a semi-deployed state.
17+
concurrency:
18+
group: "deploy-documentation"
19+
cancel-in-progress: false
20+
21+
jobs:
22+
deploy-documentation:
23+
name: Deploy Documentation
24+
environment:
25+
name: github-pages
26+
url: ${{ steps.deployment.outputs.page_url }}
27+
runs-on: macos-15
28+
steps:
29+
- name: Checkout
30+
uses: actions/checkout@v3
31+
- name: Select Xcode
32+
run: sudo xcode-select -s /Applications/Xcode_16.3.app
33+
- name: Set Up GitHub Pages
34+
uses: actions/configure-pages@v3
35+
- name: Build Documentation
36+
run: |
37+
xcodebuild docbuild \
38+
-scheme Weakify \
39+
-derivedDataPath /tmp/DerivedData \
40+
-destination 'generic/platform=iOS';
41+
mkdir _site;
42+
$(xcrun --find docc) process-archive \
43+
transform-for-static-hosting /tmp/DerivedData/Build/Products/Debug-iphoneos/Weakify.doccarchive \
44+
--hosting-base-path Weakify \
45+
--output-path _site;
46+
- name: Create index.html
47+
run: |
48+
echo "<script>window.location.href += \"/documentation/weakify\"</script>" > _site/index.html;
49+
- name: Upload Documentation Artifact to GitHub Pages
50+
uses: actions/upload-pages-artifact@v3
51+
with:
52+
path: _site
53+
- name: Deploy to GitHub Pages
54+
id: deployment
55+
uses: actions/deploy-pages@v4

.github/workflows/test.yml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch:
9+
10+
concurrency:
11+
group: ${{ github.ref_name }}
12+
cancel-in-progress: true
13+
14+
jobs:
15+
get-environment-details:
16+
strategy:
17+
matrix:
18+
include:
19+
- os: macos-15
20+
xcode: '16.3'
21+
name: Get Environment Details (Xcode ${{ matrix.xcode }})
22+
runs-on: ${{ matrix.os }}
23+
steps:
24+
- name: Select Xcode
25+
run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
26+
- name: Print OS SDKs
27+
run: xcodebuild -version -sdk
28+
- name: Print simulators
29+
run: |
30+
xcrun simctl delete unavailable
31+
xcrun simctl list
32+
test:
33+
needs: get-environment-details
34+
strategy:
35+
matrix:
36+
include:
37+
- os: macos-15
38+
xcode: '16.3'
39+
platform: iOS
40+
destination: "name=iPhone 16 Pro"
41+
sdk: iphonesimulator
42+
- os: macos-15
43+
xcode: '16.3'
44+
platform: tvOS
45+
destination: "name=Apple TV 4K (3rd generation)"
46+
sdk: appletvsimulator
47+
- os: macos-15
48+
xcode: '16.3'
49+
platform: visionOS
50+
destination: "name=Apple Vision Pro"
51+
sdk: xrsimulator
52+
- os: macos-15
53+
xcode: '16.3'
54+
platform: watchOS
55+
destination: "name=Apple Watch Ultra 2 (49mm)"
56+
sdk: watchsimulator
57+
- os: macos-15
58+
xcode: '16.3'
59+
platform: macOS
60+
name: Test ${{ matrix.platform }} (Xcode ${{ matrix.xcode }})
61+
runs-on: ${{ matrix.os }}
62+
steps:
63+
- name: Checkout project
64+
uses: actions/checkout@master
65+
- name: Select Xcode
66+
run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
67+
- name: Run tests (Xcode)
68+
if: matrix.platform != 'macOS'
69+
run: |
70+
set -o pipefail
71+
xcodebuild clean test -scheme Weakify -sdk ${{ matrix.sdk }} -destination "${{ matrix.destination }}" -configuration Debug -enableCodeCoverage YES | xcpretty -c
72+
- name: Run tests (Swift)
73+
if: matrix.platform == 'macOS'
74+
run: swift test

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
xcuserdata/
5+
DerivedData/
6+
.swiftpm/configuration/registries.json
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.netrc
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>FILEHEADER</key>
6+
<string>
7+
// Copyright © ___YEAR___ Kyle Hughes. All rights reserved.
8+
// SPDX-License-Identifier: MIT
9+
//</string>
10+
</dict>
11+
</plist>

Package.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// swift-tools-version: 6.1
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "Weakify",
7+
products: [
8+
.library(
9+
name: "Weakify",
10+
targets: [
11+
"Weakify",
12+
]
13+
),
14+
],
15+
targets: [
16+
.target(
17+
name: "Weakify"
18+
),
19+
.testTarget(
20+
name: "WeakifyTests",
21+
dependencies: [
22+
"Weakify",
23+
]
24+
),
25+
]
26+
)

README.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Weakify
2+
3+
[![Platform Versions](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fkylehughes%2FWeakify%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/kylehughes/Weakify)
4+
[![Swift Versions](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fkylehughes%2FWeakify%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/kylehughes/Weakify)
5+
[![Test](https://github.com/kylehughes/Weakify/actions/workflows/test.yml/badge.svg)](https://github.com/kylehughes/Weakify/actions/workflows/test.yml)
6+
7+
*A simple, ergonomic solution for safely weakly and unownedly capturing methods in Swift.*
8+
9+
## About
10+
11+
Weakify provides convenient and safe mechanisms for capturing method references weakly or unownedly, ensuring closures referencing methods on an object do not inadvertently extend the object's lifetime. It relies on unapplied method references, and uses parameter packs to support heterogeneous argument lists.
12+
13+
This package encourages:
14+
15+
* Single-line syntax for capturing method references.
16+
* Avoidance of retain cycles through `weak` or `unowned` captures.
17+
18+
Weakify is extremely lightweight and has a robust test suite.
19+
20+
## Capabilities
21+
22+
* [x] Weakly capture method references with automatic fallback values.
23+
* [x] Unowned capture for non-optional scenarios.
24+
* [x] Ergonomic handling of heterogenous arguments.
25+
* [x] Swift 6 language mode support.
26+
27+
## Supported Platforms
28+
29+
* iOS 13.0+
30+
* macOS 10.15+
31+
* tvOS 13.0+
32+
* visionOS 1.0+
33+
* watchOS 6.0+
34+
35+
## Requirements
36+
37+
* Xcode 16.3+
38+
39+
## Installation
40+
41+
### Swift Package Manager
42+
43+
```swift
44+
dependencies: [
45+
.package(url: "https://github.com/kylehughes/Weakify.git", .upToNextMajor(from: "1.0.0")),
46+
]
47+
```
48+
49+
## Quick Start
50+
51+
Weakly capturing a method reference:
52+
53+
```swift
54+
import Weakify
55+
56+
class MyViewController: UIViewController {
57+
private lazy var button: UIButton = {
58+
let button = UIButton()
59+
button.addAction(
60+
UIAction(handler: weakify(MyViewController.buttonTapped, on: self)),
61+
for: .primaryActionTriggered
62+
)
63+
return button
64+
}()
65+
66+
private func buttonTapped(_ action: UIAction) {
67+
print("Button tapped")
68+
}
69+
}
70+
```
71+
72+
Unownedly capturing a method reference:
73+
74+
```swift
75+
import Weakify
76+
77+
class MyViewController: UIViewController {
78+
func observe(notificationCenter: NotificationCenter) {
79+
notificationCenter.addObserver(
80+
forName: .myNotification,
81+
object: nil,
82+
queue: .main,
83+
using: disown(MyViewController.handleNotification, on: self)
84+
)
85+
}
86+
87+
private func handleNotification(_ notification: Notification) {
88+
print("Notification received")
89+
}
90+
}
91+
```
92+
93+
## Usage
94+
95+
### Weak Capture with Default
96+
97+
Use `weakify` to safely capture `self` without retaining it. A default fallback value can be provided for when `self` has been deallocated:
98+
99+
```swift
100+
let weakHandler = weakify(MyViewController.formatMessage(_:count:), on: self, default: "N/A")
101+
```
102+
103+
### Unowned Capture
104+
105+
Use `disown` when the target object will definitely outlive the closure:
106+
107+
```swift
108+
let unownedHandler = disown(MyViewController.updateStatus(_:), on: self)
109+
```
110+
111+
### Heterogeneous Arguments
112+
113+
Weakify supports methods with heterogeneous argument lists:
114+
115+
```swift
116+
func formatMessage(_ prefix: String, count: Int) -> String {
117+
"\(prefix): \(count)"
118+
}
119+
120+
let formatter = weakify(MyViewController.formatMessage, on: self, default: "default")
121+
formatter("Count", 5)
122+
```
123+
124+
## Important Behavior
125+
126+
* `weakify` closures evaluate the provided default when the target is deallocated.
127+
* `disown` closures will crash if called after the target is deallocated; ensure the target outlives the closure.
128+
129+
## Contributions
130+
131+
Weakify is not accepting source contributions at this time. Bug reports will be considered.
132+
133+
## Author
134+
135+
[Kyle Hughes](https://kylehugh.es)
136+
137+
[![Bluesky][bluesky_image]][bluesky_url]
138+
[![LinkedIn][linkedin_image]][linkedin_url]
139+
[![Mastodon][mastodon_image]][mastodon_url]
140+
141+
[bluesky_image]: https://img.shields.io/badge/Bluesky-0285FF?logo=bluesky&logoColor=fff
142+
[bluesky_url]: https://bsky.app/profile/kylehugh.es
143+
[linkedin_image]: https://img.shields.io/badge/LinkedIn-0A66C2?logo=linkedin&logoColor=fff
144+
[linkedin_url]: https://www.linkedin.com/in/kyle-hughes
145+
[mastodon_image]: https://img.shields.io/mastodon/follow/109356914477272810?domain=https%3A%2F%2Fmister.computer&style=social
146+
[mastodon_url]: https://mister.computer/@kyle
147+
148+
## License
149+
150+
Weakify is available under the MIT license.
151+
152+
See `LICENSE` for details.

Sources/Weakify/Disown.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//
2+
// Copyright © 2025 Kyle Hughes. All rights reserved.
3+
// SPDX-License-Identifier: MIT
4+
//
5+
6+
// MARK: - Global Public Interface -
7+
8+
/// Creates a closure that unownedly captures the given `target` and invokes the supplied unapplied method
9+
/// reference.
10+
///
11+
/// An unapplied method reference (UMR) is the function value produced by writing an instance method on the type
12+
/// instead of an instance, for example `UIView.layoutIfNeeded` or `Array.append`. Its curried shape is
13+
/// `(Self) -> (Args…) -> Result`, so supplying a specific instance—`closureProvider(target)`—yields the regular
14+
/// `(Args…) -> Result` closure you would normally call.
15+
///
16+
/// The returned closure can be stored safely without extending the lifetime of `target`.
17+
///
18+
/// - Parameter unappliedMethodReference: Unapplied method reference on `Target`.
19+
/// - Parameter target: The object to be captured unownedly.
20+
/// - Returns: A closure with the same arity and return type as the method referenced by `unappliedMethodReference`,
21+
/// but which is safe to store without extending the lifetime of `target`.
22+
@inlinable
23+
public func disown<Target, Output, each Argument>(
24+
_ unappliedMethodReference: @escaping (Target) -> (repeat each Argument) -> Output,
25+
on target: Target
26+
) -> (repeat each Argument) -> Output where Target: AnyObject {
27+
{ [unowned target] (argument: repeat each Argument) in
28+
unappliedMethodReference(target)(repeat each argument)
29+
}
30+
}
31+
32+
/// Creates a `Sendable` closure that unownedly captures the given `target` and invokes the supplied `Sendable`
33+
/// unapplied method reference.
34+
///
35+
/// An unapplied method reference (UMR) is the function value produced by writing an instance method on the type
36+
/// instead of an instance, for example `UIView.layoutIfNeeded` or `Array.append`. Its curried shape is
37+
/// `(Self) -> (Args…) -> Result`, so supplying a specific instance—`closureProvider(target)`—yields the regular
38+
/// `(Args…) -> Result` closure you would normally call.
39+
///
40+
/// The returned `Sendable` closure can be stored safely without extending the lifetime of `target`.
41+
///
42+
/// - Parameter unappliedMethodReference: Unapplied method reference on `Target`.
43+
/// - Parameter target: The `Sendable` object to be captured unownedly.
44+
/// - Returns: A `Sendable` closure with the same arity and return type as the method referenced by
45+
/// `unappliedMethodReference`, but which is safe to store without extending the lifetime of `target`.
46+
@inlinable
47+
public func disown<Target, Output, each Argument>(
48+
_ unappliedMethodReference: @Sendable @escaping (Target) -> (repeat each Argument) -> Output,
49+
on target: Target
50+
) -> @Sendable (repeat each Argument) -> Output where Target: AnyObject & Sendable {
51+
{ [unowned target] (argument: repeat each Argument) in
52+
unappliedMethodReference(target)(repeat each argument)
53+
}
54+
}

0 commit comments

Comments
 (0)