Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## Agent Guidelines

1. **Test-Driven Mindset**
- Treat `swift test` (or equivalent) as part of every instruction unless the user explicitly opts out.
- When feasible, add or adjust tests to capture new behavior before changing implementation details.
- If tests must be skipped, call it out and explain why.

2. **Iterate on Existing Code**
- After implementing a change, re‑evaluate nearby code for redundancy, dead paths, or simplifications that the update enables.
- Remove obsolete helpers, APIs, or comments when they no longer add value.
- Keep diffs focused: don’t mix unrelated cleanups unless they fall out naturally from the requested work.

3. **README Stewardship**
- Ensure `README.md` remains short, accurate, and directly related to the current library surface.
- Update usage snippets, API lists, or caveats whenever behavior changes.
- Avoid marketing fluff; prioritize concise explanations and practical examples.

4. **Communication Notes**
- When parameterizing tests or configurations, prefer explicit argument lists over computed helpers so intent stays readable.
- Call out any assumptions, limitations, or skipped steps (e.g., networking constraints, slow tests).
- If an instruction conflicts with these guidelines, follow the instruction but highlight the divergence.
40 changes: 28 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,42 @@ Add the dependency and product to your `Package.swift`:

```swift
import StaticMemberIterable
import SwiftUI

@StaticMemberIterable
struct Coffee {
let name: String
let roastLevel: Int

static let sunrise = Coffee(name: "sunrise", roastLevel: 2)
static let moonlight = Coffee(name: "moonlight", roastLevel: 3)
static let stardust = Coffee(name: "stardust", roastLevel: 4)
enum ColorPalette {
static let sunrise: Color = Color(red: 1.00, green: 0.58, blue: 0.22)
static let moonlight: Color = Color(red: 0.30, green: 0.32, blue: 0.60)
static let stardust: Color = Color(red: 0.68, green: 0.51, blue: 0.78)
}

Coffee.allStaticMembers // [sunrise, moonlight, stardust]
Coffee.allStaticMemberNames // ["sunrise", "moonlight", "stardust"] as [StaticMemberName]
Coffee.allNamedStaticMembers // [(name: "sunrise", value: sunrise), ...] as [(name: StaticMemberName, value: Coffee)]

Coffee.allStaticMemberNames.map(\.title) // ["Sunrise", "Moonlight", "Stardust"]
ColorPalette.allStaticMembers.map(\.value) // [.orange, .indigo, .purple] as [Color]
ColorPalette.allStaticMembers.map(\.title) // ["Sunrise", "Moonlight", "Stardust"]
ColorPalette.allStaticMembers.map(\.keyPath) // [\ColorPalette.sunrise, ...] as [KeyPath<ColorPalette.Type, Color>]
```

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

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:

```swift
ForEach(ColorPalette.allStaticMembers) { $color in
RoundedRectangle(cornerRadius: 12)
.fill(color)
.overlay(Text($color.title))
.tag($color.id)
}
```

`StaticMember` exposes four pieces of data:

- `name: String` – keeps the original identifier for the member.
- `title: String` – human-friendly representation derived from the identifier.
- `keyPath: KeyPath<Container.Type, Value>` – points back to the static property inside the declaring type.
- `value`/`wrappedValue: Value` – the actual static instance.

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

### Access control

Need public-facing lists? Pass the desired access modifier:
Expand Down
131 changes: 131 additions & 0 deletions Sources/StaticMemberIterable/StaticMember.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import Foundation

public typealias StaticMemberOf<Container: StaticMemberIterable> = StaticMember<Container, Container.StaticMemberValue>

@propertyWrapper
public struct StaticMember<Container, Value>: Identifiable {
public typealias ID = KeyPath<Container.Type, Value>

public let keyPath: KeyPath<Container.Type, Value>
public let name: String

private let storage: Value

public var wrappedValue: Value { storage }
public var projectedValue: StaticMember<Container, Value> { self }
public var value: Value { storage }
public var title: String { name.memberIdentifierTitle() }
public var id: ID { keyPath }

public init(keyPath: KeyPath<Container.Type, Value>, name: String, value: Value) {
self.keyPath = keyPath
self.name = name
self.storage = value
}

public init(projectedValue: StaticMember<Container, Value>) {
self.init(keyPath: projectedValue.keyPath, name: projectedValue.name, value: projectedValue.value)
}

public static func ~= (
keyPath: ID,
staticMember: StaticMember<Container, Value>
) -> Bool {
staticMember.keyPath == keyPath
}
}

extension StaticMember: @unchecked Sendable where Value: Sendable {}

extension String {
fileprivate func memberIdentifierTitle() -> String {
let words = memberIdentifierWords()
guard !words.isEmpty else { return self }
return words
.map { word in
if word == word.uppercased() {
return word
}

guard let first = word.first else { return word }
let remainder = word.dropFirst().lowercased()
return String(first).uppercased() + remainder
}
.joined(separator: " ")
}

private func memberIdentifierWords() -> [String] {
guard !isEmpty else { return [] }

var words: [String] = []
var current = ""
let characters = Array(self)

func flush() {
if !current.isEmpty {
words.append(current)
current.removeAll(keepingCapacity: true)
}
}

for index in characters.indices {
let character = characters[index]

if character.isWordSeparator {
flush()
continue
}

if index > 0 {
let previous = characters[index - 1]
let next = index + 1 < characters.count ? characters[index + 1] : nil
if character.shouldInsertBreak(before: previous, next: next) {
flush()
}
}

current.append(character)
}

flush()

return words
}
}

extension Character {
private var isLetter: Bool {
unicodeScalars.allSatisfy(CharacterSet.letters.contains)
}

private var isUppercaseLetter: Bool {
unicodeScalars.allSatisfy(CharacterSet.uppercaseLetters.contains)
}

private var isLowercaseLetter: Bool {
unicodeScalars.allSatisfy(CharacterSet.lowercaseLetters.contains)
}

private var isNumber: Bool {
unicodeScalars.allSatisfy(CharacterSet.decimalDigits.contains)
}

fileprivate var isWordSeparator: Bool {
self == "_" || self == "-" || self == " "
}

fileprivate func shouldInsertBreak(before previous: Character, next: Character?) -> Bool {
switch true {
case previous.isLowercaseLetter && isUppercaseLetter:
true
case previous.isLowercaseLetter && isNumber:
true
case previous.isNumber && isLetter:
true
case previous.isUppercaseLetter && isUppercaseLetter && (next?.isLowercaseLetter ?? false):
true
default:
false
}
}
}
10 changes: 9 additions & 1 deletion Sources/StaticMemberIterable/StaticMemberIterable.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
public protocol StaticMemberIterable {
associatedtype StaticMemberValue
}

@attached(
member,
names: named(allStaticMembers), named(allStaticMemberNames), named(allNamedStaticMembers)
names: named(StaticMemberValue), named(allStaticMembers)
)
@attached(
extension,
conformances: StaticMemberIterable
)
public macro StaticMemberIterable(
_ access: StaticMemberIterableAccess? = nil,
Expand Down
120 changes: 0 additions & 120 deletions Sources/StaticMemberIterable/StaticMemberName.swift

This file was deleted.

Loading
Loading