Skip to content

Commit 22bcc91

Browse files
authored
Simplify by introducing a StaticMember type (#1)
1 parent 197d695 commit 22bcc91

File tree

11 files changed

+700
-412
lines changed

11 files changed

+700
-412
lines changed

AGENTS.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
## Agent Guidelines
2+
3+
1. **Test-Driven Mindset**
4+
- Treat `swift test` (or equivalent) as part of every instruction unless the user explicitly opts out.
5+
- When feasible, add or adjust tests to capture new behavior before changing implementation details.
6+
- If tests must be skipped, call it out and explain why.
7+
8+
2. **Iterate on Existing Code**
9+
- After implementing a change, re‑evaluate nearby code for redundancy, dead paths, or simplifications that the update enables.
10+
- Remove obsolete helpers, APIs, or comments when they no longer add value.
11+
- Keep diffs focused: don’t mix unrelated cleanups unless they fall out naturally from the requested work.
12+
13+
3. **README Stewardship**
14+
- Ensure `README.md` remains short, accurate, and directly related to the current library surface.
15+
- Update usage snippets, API lists, or caveats whenever behavior changes.
16+
- Avoid marketing fluff; prioritize concise explanations and practical examples.
17+
18+
4. **Communication Notes**
19+
- When parameterizing tests or configurations, prefer explicit argument lists over computed helpers so intent stays readable.
20+
- Call out any assumptions, limitations, or skipped steps (e.g., networking constraints, slow tests).
21+
- If an instruction conflicts with these guidelines, follow the instruction but highlight the divergence.

README.md

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,42 @@ Add the dependency and product to your `Package.swift`:
2020

2121
```swift
2222
import StaticMemberIterable
23+
import SwiftUI
2324

2425
@StaticMemberIterable
25-
struct Coffee {
26-
let name: String
27-
let roastLevel: Int
28-
29-
static let sunrise = Coffee(name: "sunrise", roastLevel: 2)
30-
static let moonlight = Coffee(name: "moonlight", roastLevel: 3)
31-
static let stardust = Coffee(name: "stardust", roastLevel: 4)
26+
enum ColorPalette {
27+
static let sunrise: Color = Color(red: 1.00, green: 0.58, blue: 0.22)
28+
static let moonlight: Color = Color(red: 0.30, green: 0.32, blue: 0.60)
29+
static let stardust: Color = Color(red: 0.68, green: 0.51, blue: 0.78)
3230
}
3331

34-
Coffee.allStaticMembers // [sunrise, moonlight, stardust]
35-
Coffee.allStaticMemberNames // ["sunrise", "moonlight", "stardust"] as [StaticMemberName]
36-
Coffee.allNamedStaticMembers // [(name: "sunrise", value: sunrise), ...] as [(name: StaticMemberName, value: Coffee)]
37-
38-
Coffee.allStaticMemberNames.map(\.title) // ["Sunrise", "Moonlight", "Stardust"]
32+
ColorPalette.allStaticMembers.map(\.value) // [.orange, .indigo, .purple] as [Color]
33+
ColorPalette.allStaticMembers.map(\.title) // ["Sunrise", "Moonlight", "Stardust"]
34+
ColorPalette.allStaticMembers.map(\.keyPath) // [\ColorPalette.sunrise, ...] as [KeyPath<ColorPalette.Type, Color>]
3935
```
4036

4137
The macro works the same for enums and classes (actors are intentionally unsupported so far).
4238

39+
Each synthesized entry is a `StaticMember<Container, Value>`: an `Identifiable` property wrapper that stores the friendly name, the `KeyPath` to the static property, and the concrete value. This makes it trivial to drive UI:
40+
41+
```swift
42+
ForEach(ColorPalette.allStaticMembers) { $color in
43+
RoundedRectangle(cornerRadius: 12)
44+
.fill(color)
45+
.overlay(Text($color.title))
46+
.tag($color.id)
47+
}
48+
```
49+
50+
`StaticMember` exposes four pieces of data:
51+
52+
- `name: String` – keeps the original identifier for the member.
53+
- `title: String` – human-friendly representation derived from the identifier.
54+
- `keyPath: KeyPath<Container.Type, Value>` – points back to the static property inside the declaring type.
55+
- `value`/`wrappedValue: Value` – the actual static instance.
56+
57+
Because it is a property wrapper, you can also project (`$member`) when you use it on your own properties, and `Identifiable` conformance makes it slot neatly into `ForEach`.
58+
4359
### Access control
4460

4561
Need public-facing lists? Pass the desired access modifier:
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import Foundation
2+
3+
public typealias StaticMemberOf<Container: StaticMemberIterable> = StaticMember<Container, Container.StaticMemberValue>
4+
5+
@propertyWrapper
6+
public struct StaticMember<Container, Value>: Identifiable {
7+
public typealias ID = KeyPath<Container.Type, Value>
8+
9+
public let keyPath: KeyPath<Container.Type, Value>
10+
public let name: String
11+
12+
private let storage: Value
13+
14+
public var wrappedValue: Value { storage }
15+
public var projectedValue: StaticMember<Container, Value> { self }
16+
public var value: Value { storage }
17+
public var title: String { name.memberIdentifierTitle() }
18+
public var id: ID { keyPath }
19+
20+
public init(keyPath: KeyPath<Container.Type, Value>, name: String, value: Value) {
21+
self.keyPath = keyPath
22+
self.name = name
23+
self.storage = value
24+
}
25+
26+
public init(projectedValue: StaticMember<Container, Value>) {
27+
self.init(keyPath: projectedValue.keyPath, name: projectedValue.name, value: projectedValue.value)
28+
}
29+
30+
public static func ~= (
31+
keyPath: ID,
32+
staticMember: StaticMember<Container, Value>
33+
) -> Bool {
34+
staticMember.keyPath == keyPath
35+
}
36+
}
37+
38+
extension StaticMember: @unchecked Sendable where Value: Sendable {}
39+
40+
extension String {
41+
fileprivate func memberIdentifierTitle() -> String {
42+
let words = memberIdentifierWords()
43+
guard !words.isEmpty else { return self }
44+
return words
45+
.map { word in
46+
if word == word.uppercased() {
47+
return word
48+
}
49+
50+
guard let first = word.first else { return word }
51+
let remainder = word.dropFirst().lowercased()
52+
return String(first).uppercased() + remainder
53+
}
54+
.joined(separator: " ")
55+
}
56+
57+
private func memberIdentifierWords() -> [String] {
58+
guard !isEmpty else { return [] }
59+
60+
var words: [String] = []
61+
var current = ""
62+
let characters = Array(self)
63+
64+
func flush() {
65+
if !current.isEmpty {
66+
words.append(current)
67+
current.removeAll(keepingCapacity: true)
68+
}
69+
}
70+
71+
for index in characters.indices {
72+
let character = characters[index]
73+
74+
if character.isWordSeparator {
75+
flush()
76+
continue
77+
}
78+
79+
if index > 0 {
80+
let previous = characters[index - 1]
81+
let next = index + 1 < characters.count ? characters[index + 1] : nil
82+
if character.shouldInsertBreak(before: previous, next: next) {
83+
flush()
84+
}
85+
}
86+
87+
current.append(character)
88+
}
89+
90+
flush()
91+
92+
return words
93+
}
94+
}
95+
96+
extension Character {
97+
private var isLetter: Bool {
98+
unicodeScalars.allSatisfy(CharacterSet.letters.contains)
99+
}
100+
101+
private var isUppercaseLetter: Bool {
102+
unicodeScalars.allSatisfy(CharacterSet.uppercaseLetters.contains)
103+
}
104+
105+
private var isLowercaseLetter: Bool {
106+
unicodeScalars.allSatisfy(CharacterSet.lowercaseLetters.contains)
107+
}
108+
109+
private var isNumber: Bool {
110+
unicodeScalars.allSatisfy(CharacterSet.decimalDigits.contains)
111+
}
112+
113+
fileprivate var isWordSeparator: Bool {
114+
self == "_" || self == "-" || self == " "
115+
}
116+
117+
fileprivate func shouldInsertBreak(before previous: Character, next: Character?) -> Bool {
118+
switch true {
119+
case previous.isLowercaseLetter && isUppercaseLetter:
120+
true
121+
case previous.isLowercaseLetter && isNumber:
122+
true
123+
case previous.isNumber && isLetter:
124+
true
125+
case previous.isUppercaseLetter && isUppercaseLetter && (next?.isLowercaseLetter ?? false):
126+
true
127+
default:
128+
false
129+
}
130+
}
131+
}

Sources/StaticMemberIterable/StaticMemberIterable.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1+
public protocol StaticMemberIterable {
2+
associatedtype StaticMemberValue
3+
}
4+
15
@attached(
26
member,
3-
names: named(allStaticMembers), named(allStaticMemberNames), named(allNamedStaticMembers)
7+
names: named(StaticMemberValue), named(allStaticMembers)
8+
)
9+
@attached(
10+
extension,
11+
conformances: StaticMemberIterable
412
)
513
public macro StaticMemberIterable(
614
_ access: StaticMemberIterableAccess? = nil,

Sources/StaticMemberIterable/StaticMemberName.swift

Lines changed: 0 additions & 120 deletions
This file was deleted.

0 commit comments

Comments
 (0)