Introducing `StaticMemberIterable` Swift Macro!

StaticMemberIterable

Confidently cover all static members. Like CaseIterable, this macro creates an array of all the static members of a type. This is useful when a type has a few examples as static members.

Here, we have a Chili type so that we can discuss the heat level and names of various chilis. So far, we have two: jalapeño and habenero. For testing or for displaying in the UI, we want to confidently list both the jalapeño & the habenero. With @StaticMemberIterable, we can finally do so.

@StaticMemberIterable
struct Chili {
  let name: String
  let heatLevel: Int
  
  static let jalapeño = Chili(name: "jalapeño", heatLevel: 2)
  static let habenero = Chili(name: "habenero", heatLevel: 5)
}

expands to

struct Chili {
  let name: String
  let heatLevel: Int
  
  static let jalapeño = Chili(name: "jalapeño", heatLevel: 2)
  static let habenero = Chili(name: "habenero", heatLevel: 5)
  
  static let allStaticMembers = [jalapeño, habenero]
}

Installation

In Package.swift, add the package to your dependencies.

.package(url: "https://github.com/FullQueueDeveloper/StaticMemberIterable.git", from: "0.1.0"),

And add "StaticMemberIterable" to the list of your target's dependencies.

When prompted by Xcode, trust the macro.

GitHub link

Tests pass locally, but not on GitHub Actions because of Swift 5.9

7 Likes

Now that Swift 5.9 is out, version 1.0.0 has been tagged!

.package(url: "https://github.com/FullQueueDeveloper/StaticMemberIterable.git", 
         from: "1.0.0"),

A real-life example of StaticMemberIterable

And here's another example from some code I'm working on today. With StaticMemberIterable, I'm not limited to one "rawValue" for the enum. I can add a "short name" to also match on, when conforming to ExpressibleByArgument

I have three infrastructure-as-code directories, and I'm running Terraform through SPX to take advantage of op run to render secrets to my scripts.

If Swift Argument Parser supported short names for commands, this wouldn't be necessary. Regardless, here we are, with a real-life use of StaticMemberIterable.

First, with an enum

I first implemented this with an enum, since I prefer using first-party tools when possible.

enum TerraformDir_EnumStyle: String, ExpressibleByArgument, CaseIterable {
  case cloudflare
  case digitalocean
  case kubernetes

  var defaultValueDescription: String {
    "\(self.rawValue.yellow) or \(self.short.yellow) for short"
  }

  var short: String {
    switch self {
    case .cloudflare: "cf"
    case .digitalocean: "do"
    case .kubernetes: "k8s"
    }
  }

  init?(rawValue: String) {
    guard let found = Self.allCases.first(where: {
      $0.rawValue == rawValue || $0.short == rawValue
    }) else {
      return nil
    }

    self = found
  }
}

Second, with StaticMemberIterable

The implementation of var short: String really bugged me. The short name should be close to the raw-value name of the enum. I couldn't override the raw value though, since the long name is the actual name of the directory (and also a valid value). Then I remembered I wrote something to help with this, StaticMemberIterable. I think this reads much nicer than the enum, since the long name and short name are right next to each other.

@StaticMemberIterable
struct TerraformDir: ExpressibleByArgument {
  static let cloudflare = Self(name: "cloudflare", shortName: "cf")
  static let digitalocean = Self(name: "digitalocean", shortName: "do")
  static let kubernetes = Self(name: "kubernetes", shortName: "k8s")

  let name: String
  let shortName: String
  
  init(name: String, shortName: String) {
    self.name = name
    self.shortName = shortName
  }
  
  var defaultValueDescription: String {
    "\(self.name.yellow) or \(self.shortName.yellow) for short"
  }
  
  init?(argument: String) {
    let maybe = Self.allStaticMembers.first { $0.name == argument || $0.shortName == argument }
    
    guard let found = maybe else {
      return nil
    }
    
    self = found
  }
}

Finally, the full example

Here's the full example for context. It's less than 100 lines.

import Sh
import Foundation
import ArgumentParser
import Rainbow
import StaticMemberIterable

@main
struct Terraform: ParsableCommand {

  @Argument(help: "Which terraform dir to run. Options are \(TerraformDir.allStaticMembers.map({ $0.defaultValueDescription }).joined(separator: ", "))")
  var dir: TerraformDir

  @Argument(parsing: .allUnrecognized, help: "Arguments passed along to terraform")
  var terraformArguments: [String] = []

  mutating func run() throws {
    try sh(.terminal,
           "op run --env-file op.env -- terraform \(terraformArguments.joined(separator: " "))",
           workingDirectory: dir.name
    )
  }
}

@StaticMemberIterable
struct TerraformDir: ExpressibleByArgument {
  static let cloudflare = Self(name: "cloudflare", shortName: "cf")
  static let digitalocean = Self(name: "digitalocean", shortName: "do")
  static let kubernetes = Self(name: "kubernetes", shortName: "k8s")

  let name: String
  let shortName: String
  
  init(name: String, shortName: String) {
    self.name = name
    self.shortName = shortName
  }
  
  var defaultValueDescription: String {
    "\(self.name.yellow) or \(self.shortName.yellow) for short"
  }
  
  init?(argument: String) {
    let maybe = Self.allStaticMembers.first { $0.name == argument || $0.shortName == argument }
    
    guard let found = maybe else {
      return nil
    }
    
    self = found
  }
}

enum TerraformDir_EnumStyle: String, ExpressibleByArgument, CaseIterable {
  case cloudflare
  case digitalocean
  case kubernetes

  var defaultValueDescription: String {
    "\(self.rawValue.yellow) or \(self.short.yellow) for short"
  }

  var short: String {
    switch self {
    case .cloudflare: "cf"
    case .digitalocean: "do"
    case .kubernetes: "k8s"
    }
  }

  init?(rawValue: String) {
    guard let found = Self.allCases.first(where: {
      $0.rawValue == rawValue || $0.short == rawValue
    }) else {
      return nil
    }

    self = found
  }
}

And here is the Package.swift if you're interested.

// swift-tools-version: 5.9
import PackageDescription

let package = Package(
  name: "Terraform-SPX-Scripts",
  platforms: [
    .macOS(.v13),
  ],
  dependencies: [
    .package(url: "https://github.com/FullQueueDeveloper/Sh.git", from: "1.3.0"),
    .package(url: "https://github.com/FullQueueDeveloper/StaticMemberIterable.git", from: "1.0.0"),
    .package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.0"),
  ],
  targets: [
    .executableTarget(
      name: "tf",
      dependencies: [
        "Sh",
        "StaticMemberIterable",
        .product(name: "ArgumentParser", package: "swift-argument-parser"),
      ]),
  ]
)

Hashtag soup: #1password #SPX #terraform swiftpm #sh ArgumentParser

Any clue on why the github action fails?

@Genaro-Chris Are we looking at the same thing? GitHub Actions is passing for this repo’s latest commit. Workflow runs · FullQueueDeveloper/StaticMemberIterable · GitHub