diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index 91b994e..bd44436 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -24,12 +24,12 @@ jobs: matrix: # 'system' uses the Xcode toolchain preinstalled on macos-latest, # so breakage on current compilers surfaces even while the pinned - # version keeps validating the minimum supported toolchain. - # The pinned toolchain must run on an older image: Swift 6.0.2 - # cannot compile against the newer SDKs of current Xcode. + # version keeps validating the minimum supported toolchain (6.1, + # the floor imposed by package traits). The pinned toolchain runs + # on macos-15, whose default Xcode SDK matches the Swift 6.1 era. include: - - swift: '6.0.2' - runner: macos-14 + - swift: '6.1' + runner: macos-15 - swift: 'system' runner: macos-latest steps: @@ -40,13 +40,18 @@ jobs: uses: swift-actions/setup-swift@v2 with: swift-version: ${{ matrix.swift }} - + - name: Build run: swift build -v - + - name: Run tests run: swift test -v - + + # MacroTesting links swift-syntax as a library, which is incompatible + # with SwiftPM's prebuilt swift-syntax binaries — build from source. + - name: Run tests (Derive trait) + run: swift test -v --traits Derive --disable-experimental-prebuilts + - name: Run tests (release) run: swift test -c release -v @@ -55,17 +60,23 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - # 'latest' tracks the newest stable Swift release image. - swift: ['6.0.2', 'latest'] + # 'latest' tracks the newest stable Swift release image; 6.1 is the + # minimum supported toolchain (the floor imposed by package traits). + swift: ['6.1', 'latest'] container: swift:${{ matrix.swift }} steps: - uses: actions/checkout@v4 - + - name: Build run: swift build -v - + - name: Run tests run: swift test --parallel - + + # MacroTesting links swift-syntax as a library, which is incompatible + # with SwiftPM's prebuilt swift-syntax binaries — build from source. + - name: Run tests (Derive trait) + run: swift test --parallel --traits Derive --disable-experimental-prebuilts + - name: Run tests (release) - run: swift test -c release --parallel \ No newline at end of file + run: swift test -c release --parallel diff --git a/CHANGELOG.md b/CHANGELOG.md index ff33bcd..743a774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [2.2.0] - Unreleased + +### Added: `RandomGenerable` and the opt-in `@RandomGenerable` derivation macro + +- New `RandomGenerable` protocol (always available, no trait required): a type exposes + a canonical `static var generator`. The standard types come pre-conformed with + simple, documented defaults — fixed-width integers (full range), `Double`/`Float` + (`0...1`), `Bool`, `String` (8 alphanumerics), `Character` (ASCII letter), + `Optional` (`nil` half the time), and `Array` (0–10 elements). +- New `Derive` package trait (off by default). Enabling it unlocks the + `@RandomGenerable` macro, which derives a `RandomGenerable` conformance for structs + (via the memberwise initializer) and enums (uniform case selection, associated + values included), plus the `@Gen(...)` attribute to override the generator for a + single stored property. Derived generators are ordinary generators: all combinators + and seeded RNGs apply. +- The macro's swift-syntax dependency is only built by consumers who enable the + trait; with the trait off (the default), SwiftRandomKit remains a + zero-build-cost dependency. swift-syntax is still *fetched* during dependency + resolution on current SwiftPM versions, but never compiled. + +### Changed + +- `swift-tools-version` bumped from 6.0 to 6.1, required for package traits. + Consumers need a Swift 6.1+ toolchain (Xcode 16.3+); older toolchains keep + resolving 2.1.0. +- CI now runs the test suite in both trait configurations, and the pinned + minimum-toolchain job moves from Swift 6.0.2 to 6.1. + + ## [2.1.0] - 2026-07-10 No library changes. The test suite (37 suites, 179 tests) is migrated from XCTest to diff --git a/Package.resolved b/Package.resolved index 790babd..058d2e3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fd5c37f0735d36813ad0c2a81d4a84a2ae9a9e21eb420f4396a91435bb5f9379", + "originHash" : "459db807c74b539d0ef55cc4f2594a6cdb14e01c8637b546ad445a8055a857a2", "pins" : [ { "identity" : "swift-docc-plugin", @@ -18,6 +18,15 @@ "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", "version" : "1.0.0" } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index d93f95b..855e650 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,7 @@ -// swift-tools-version: 6.0.0 +// swift-tools-version: 6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. +import CompilerPluginSupport import PackageDescription let package = Package( @@ -10,13 +11,36 @@ let package = Package( .library(name: "SwiftRandomKit", targets: ["SwiftRandomKit"]), .library(name: "SwiftRandomKitGenerators", targets: ["SwiftRandomKitGenerators"]) ], + traits: [ + .trait( + name: "Derive", + description: """ + Enables the @RandomGenerable derivation macro. Opting in adds a \ + build-time dependency on swift-syntax. + """ + ) + ], dependencies: [ - .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.0") + .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.0"), + .package(url: "https://github.com/swiftlang/swift-syntax", "600.0.0"..<"700.0.0"), + .package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.6.0") ], targets: [ - .target(name: "SwiftRandomKit"), + .target( + name: "SwiftRandomKit", + dependencies: [ + .target(name: "SwiftRandomKitMacros", condition: .when(traits: ["Derive"])) + ] + ), + .macro( + name: "SwiftRandomKitMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ] + ), .target(name: "SwiftRandomKitGenerators", dependencies: ["SwiftRandomKit"]), - + .executableTarget(name: "SwiftRandomKitExample", dependencies: ["SwiftRandomKit", "SwiftRandomKitGenerators"]), .testTarget( @@ -26,6 +50,17 @@ let package = Package( "SwiftRandomKitGenerators" ], exclude: ["SwiftRandomKit-Package.xctestplan"] + ), + + // Macro expansion tests live in their own target that only imports the + // macro implementation and MacroTesting; both dependencies exist only + // when the Derive trait is enabled, and the sources are gated on it. + .testTarget( + name: "SwiftRandomKitMacrosTests", + dependencies: [ + .target(name: "SwiftRandomKitMacros", condition: .when(traits: ["Derive"])), + .product(name: "MacroTesting", package: "swift-macro-testing", condition: .when(traits: ["Derive"])) + ] ) ] ) diff --git a/Readme.md b/Readme.md index 1b31cdb..bff399e 100644 --- a/Readme.md +++ b/Readme.md @@ -13,6 +13,7 @@ SwiftRandomKit is a powerful Swift library that provides a composable, protocol- - 🔄 Support for custom random number generators - 📦 Rich set of built-in generators - 🛠 Extensive collection of combinators +- 🧬 Opt-in `@RandomGenerable` macro that derives generators for your own types (via the `Derive` package trait) ## Concurrency Safety @@ -34,11 +35,20 @@ ordinary values, confined to the isolation domain where you build and run them: ### Swift Package Manager -Add the following to your `Package.swift` file: +Add the following to your `Package.swift` file (Swift 6.1+ toolchain required): ```swift dependencies: [ - .package(url: "https://github.com/ibrahimkteish/SwiftRandomKit.git", from: "2.0.0") + .package(url: "https://github.com/ibrahimkteish/SwiftRandomKit.git", from: "2.2.0") +] +``` + +To also enable the `@RandomGenerable` derivation macro, opt into the `Derive` trait +(see [Deriving Generators](#deriving-generators-with-randomgenerable)): + +```swift +dependencies: [ + .package(url: "https://github.com/ibrahimkteish/SwiftRandomKit.git", from: "2.2.0", traits: ["Derive"]) ] ``` @@ -89,6 +99,95 @@ let roll3 = diceGen(using: &myRNG) This syntax provides a more concise and natural way to generate random values, making your code cleaner and more expressive. +## Deriving Generators with @RandomGenerable + +SwiftRandomKit can derive a generator for your own structs and enums from a single +attribute — ideal for test fixtures, SwiftUI preview data, and seeded demo content: + +```swift +import SwiftRandomKit + +@RandomGenerable +struct User { + @Gen(IntGenerator(in: 18...99)) var age: Int + var name: String // String default: 8 alphanumeric characters + var isAdmin: Bool + var nickname: String? // Optional default: nil half the time +} + +let user = User.generator.run() // User(age: 42, name: "k0bSoKdI", ...) +let team = User.generator.array(5).run() // five random users + +// Derived generators are ordinary generators: combinators and seeded RNGs work. +var rng = LCRNG(seed: 42) +let admin = User.generator.filter(\.isAdmin).run(using: &rng) +``` + +Enums derive too, including associated values — a case is picked uniformly at random: + +```swift +@RandomGenerable +enum Reward { + case coins(Int) + case badge(name: String) + case nothing +} +``` + +How derivation works: + +- Structs are built through their memberwise initializer. Every stored property is + generated by its `@Gen(...)` override if it has one, otherwise by its type's default + generator (see below). Constants with a value (`let version = 1`), `static`, `lazy`, + and computed properties are skipped. +- Enums pick a case uniformly at random and generate associated values the same way as + struct properties. +- Classes, actors, and generic types are not supported. + +### The Derive trait + +The macro needs swift-syntax to compile, which is a heavy build-time dependency — so it +is opt-in behind the `Derive` package trait. With the trait disabled (the default), +swift-syntax is never built and SwiftRandomKit stays a zero-build-cost dependency. + +- **Package.swift consumers** (Swift 6.1+): declare the dependency with + `traits: ["Derive"]` as shown in [Installation](#installation). +- **Xcode app projects**: Xcode 26.4+ supports enabling package traits on a dependency. + On older Xcode versions, wrap SwiftRandomKit in a local package that enables the + trait and re-exports it. + +### Default generators (`RandomGenerable`) + +The `RandomGenerable` protocol — available with or without the trait — is what powers +derivation: a type conforms by exposing a canonical `static var generator`. The +standard types come pre-conformed with deliberately simple defaults: + +| Type | Default | +| --- | --- | +| `Int`, `UInt8`, ... (all fixed-width integers) | uniform over the type's full range | +| `Double`, `Float` | uniform over `0...1` | +| `Bool` | fair coin flip | +| `String` | 8 random alphanumeric characters | +| `Character` | random ASCII letter | +| `Optional` | `T`'s default, `nil` half the time | +| `Array` | 0–10 elements of `T`'s default | + +When a default doesn't fit, override per property with `@Gen(...)`, or conform your own +leaf types manually — no macro needed: + +```swift +struct Temperature { var celsius: Double } + +extension Temperature: RandomGenerable { + static var generator: some RandomGenerator { + FloatGenerator(in: -40...50).map(Temperature.init) + } +} +``` + +Types that conform — manually or via the macro — compose into other derived types +automatically: a `@RandomGenerable` struct with a `Temperature` property just works. + ## Built-in Generators ### Core Generators diff --git a/Sources/SwiftRandomKit/Macros.swift b/Sources/SwiftRandomKit/Macros.swift new file mode 100644 index 0000000..dea03af --- /dev/null +++ b/Sources/SwiftRandomKit/Macros.swift @@ -0,0 +1,56 @@ +#if Derive +/// Derives a `RandomGenerable` conformance for a struct or an enum. +/// +/// Available when the package is imported with the `Derive` trait enabled. +/// +/// For a struct, the derived `generator` builds values through the memberwise +/// initializer, generating every stored property from its type's default generator — +/// or from a `@Gen(...)` override: +/// +/// ```swift +/// @RandomGenerable +/// struct User { +/// let name: String +/// @Gen(IntGenerator(in: 18...99)) let age: Int +/// let isAdmin: Bool +/// } +/// +/// let user = User.generator.run() +/// let team = User.generator.array(5).run() +/// ``` +/// +/// For an enum, the derived `generator` picks a case uniformly at random and derives +/// any associated values the same way: +/// +/// ```swift +/// @RandomGenerable +/// enum Reward { +/// case coins(Int) +/// case badge(name: String) +/// case nothing +/// } +/// ``` +/// +/// Every stored property (and associated value) type must either conform to +/// `RandomGenerable` or carry a `@Gen` override. Derivation is not supported for +/// classes, actors, or generic types. +@attached(extension, conformances: RandomGenerable, names: named(generator)) +public macro RandomGenerable() = #externalMacro(module: "SwiftRandomKitMacros", type: "RandomGenerableMacro") + +/// Overrides the generator used for one stored property in a `@RandomGenerable` type. +/// +/// Available when the package is imported with the `Derive` trait enabled. +/// +/// The expression is captured as written and re-evaluated inside the derived +/// generator, so it composes with every combinator in the library: +/// +/// ```swift +/// @RandomGenerable +/// struct Player { +/// @Gen(["north", "south", "east", "west"].element().map { $0! }) let region: String +/// @Gen(IntGenerator(in: 0...9999)) let score: Int +/// } +/// ``` +@attached(peer) +public macro Gen(_ generator: G) = #externalMacro(module: "SwiftRandomKitMacros", type: "GenMacro") +#endif diff --git a/Sources/SwiftRandomKit/RandomGenerable.swift b/Sources/SwiftRandomKit/RandomGenerable.swift new file mode 100644 index 0000000..02820b6 --- /dev/null +++ b/Sources/SwiftRandomKit/RandomGenerable.swift @@ -0,0 +1,115 @@ +/// A type that has a canonical default random generator. +/// +/// Conforming a type to `RandomGenerable` gives it a `generator` entry point that the +/// rest of the library — and code generated by the `@RandomGenerable` macro — can use +/// to produce values of the type: +/// +/// ```swift +/// let value = Int.generator.run() // any Int +/// let flag = Bool.generator.run() // true or false +/// let word = String.generator.run() // 8 random alphanumeric characters +/// ``` +/// +/// The library conforms the common standard-library types with deliberately simple, +/// documented defaults (see the extensions in this file). When a default doesn't fit — +/// an age that should be `18...99`, a name that should come from a curated list — you +/// don't change the conformance: you override at the use site, either by composing +/// generators directly or with the `@Gen` per-property override of the +/// `@RandomGenerable` macro (enabled by the `Derive` package trait). +/// +/// Custom types can conform manually by returning any generator whose `Element` is +/// `Self`: +/// +/// ```swift +/// struct Temperature { var celsius: Double } +/// +/// extension Temperature: RandomGenerable { +/// static var generator: some RandomGenerator { +/// FloatGenerator(in: -40...50).map(Temperature.init) +/// } +/// } +/// ``` +public protocol RandomGenerable { + /// The concrete generator type that produces values of this type. + associatedtype Generator: RandomGenerator where Generator.Element == Self + + /// The canonical default generator for this type. + static var generator: Generator { get } +} + +// MARK: - Integers + +extension RandomGenerable where Self: FixedWidthInteger & Sendable { + /// The default integer generator: uniform over the type's entire range. + /// + /// Full range is the only unopinionated choice, but it is rarely what a fixture + /// wants to display — override with `@Gen(IntGenerator(in: ...))` or compose your + /// own generator when you need bounded values. + public static var generator: IntGenerator { + IntGenerator(in: Self.min ... Self.max) + } +} + +extension Int: RandomGenerable {} +extension Int8: RandomGenerable {} +extension Int16: RandomGenerable {} +extension Int32: RandomGenerable {} +extension Int64: RandomGenerable {} +extension UInt: RandomGenerable {} +extension UInt8: RandomGenerable {} +extension UInt16: RandomGenerable {} +extension UInt32: RandomGenerable {} +extension UInt64: RandomGenerable {} + +// MARK: - Floating point + +extension RandomGenerable where Self: BinaryFloatingPoint & Sendable, Self.RawSignificand: FixedWidthInteger { + /// The default floating-point generator: uniform over the unit interval `0...1`. + public static var generator: FloatGenerator { + FloatGenerator(in: 0...1) + } +} + +extension Double: RandomGenerable {} +extension Float: RandomGenerable {} + +// MARK: - Bool + +extension Bool: RandomGenerable { + /// The default boolean generator: a fair coin flip. + public static var generator: BoolGenerator { + BoolGenerator() + } +} + +// MARK: - Characters and strings + +extension Character: RandomGenerable { + /// The default character generator: a random ASCII letter (a-z, A-Z). + public static var generator: some RandomGenerator { + RandomGenerators.letter + } +} + +extension String: RandomGenerable { + /// The default string generator: 8 random alphanumeric characters. + public static var generator: some RandomGenerator { + RandomGenerators.letterOrNumber.array(8).map { String($0) } + } +} + +// MARK: - Optionals and arrays + +extension Optional: RandomGenerable where Wrapped: RandomGenerable { + /// The default optional generator: the wrapped type's generator, `nil` half the time. + public static var generator: some RandomGenerator { + Wrapped.generator.orNil(probability: 0.5) + } +} + +extension Array: RandomGenerable where Element: RandomGenerable { + /// The default array generator: 0 to 10 elements from the element type's generator. + public static var generator: some RandomGenerator<[Element]> { + Element.generator.arrayGenerator(IntGenerator(in: 0...10)) + } +} diff --git a/Sources/SwiftRandomKitExample/main.swift b/Sources/SwiftRandomKitExample/main.swift index ab148f3..71c1bb5 100644 --- a/Sources/SwiftRandomKitExample/main.swift +++ b/Sources/SwiftRandomKitExample/main.swift @@ -65,3 +65,52 @@ print("Sudoku (easy):") for row in puzzle { print(row.map { $0 == 0 ? "." : "\($0)" }.joined(separator: " ")) } + +// MARK: - Derived generators (Derive trait) + +#if Derive +// Fixtures in one attribute: every stored property uses its type's default +// generator unless a @Gen override says otherwise. +@RandomGenerable +struct User: Equatable { + @Gen(["Ana", "Karl", "Ines", "Omar", "Mona", "Luis"].randomGeneratorElement().map { $0! }) + var name: String + @Gen(IntGenerator(in: 18...99)) + var age: Int + var isAdmin: Bool + var referralCode: String // String default: 8 alphanumeric characters + var nickname: String? // Optional default: nil half the time +} + +@RandomGenerable +struct Loot { + @Gen(IntGenerator(in: 1...500)) var coins: Int + @Gen(FloatGenerator(in: 0...1)) var dropChance: Double +} + +// Enums derive too: a case is picked uniformly at random, and associated +// values are generated the same way as struct properties. +@RandomGenerable +enum Reward { + case loot(Loot) + case badge(name: String) + case nothing +} + +print("\n--- Derived generators (Derive trait enabled) ---") +print("User:", User.generator.run(using: &rng)) +print("Team:", User.generator.array(3).run(using: &rng).map(\.name)) +print("Rewards:", Reward.generator.array(4).run(using: &rng)) + +// Derived generators are ordinary generators, so every combinator applies. +let seniorAdmin = User.generator.filter { $0.isAdmin && $0.age >= 60 } +print("Senior admin:", seniorAdmin.run(using: &rng)) + +// And they stay reproducible with a seeded RNG. +var replayA = LCRNG(seed: 7) +var replayB = LCRNG(seed: 7) +print( + "Same seed, same user:", + User.generator.run(using: &replayA) == User.generator.run(using: &replayB) +) +#endif diff --git a/Sources/SwiftRandomKitMacros/Plugin.swift b/Sources/SwiftRandomKitMacros/Plugin.swift new file mode 100644 index 0000000..fe84448 --- /dev/null +++ b/Sources/SwiftRandomKitMacros/Plugin.swift @@ -0,0 +1,10 @@ +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct SwiftRandomKitMacrosPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + RandomGenerableMacro.self, + GenMacro.self + ] +} diff --git a/Sources/SwiftRandomKitMacros/RandomGenerableMacro.swift b/Sources/SwiftRandomKitMacros/RandomGenerableMacro.swift new file mode 100644 index 0000000..4751681 --- /dev/null +++ b/Sources/SwiftRandomKitMacros/RandomGenerableMacro.swift @@ -0,0 +1,297 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// Implements the `@RandomGenerable` extension macro. +/// +/// For a struct, synthesizes a `RandomGenerable` conformance whose `generator` builds +/// the type through its memberwise initializer, generating each stored property with +/// either its `@Gen(...)` override or the property type's default `generator`. +/// +/// For an enum, synthesizes a conformance whose `generator` picks a case uniformly at +/// random and generates any associated values the same way. +public struct RandomGenerableMacro: ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + // The compiler passes an empty protocol list when the conformance is already + // declared (and test harnesses pass none at all). Still synthesize the + // generator; just leave the conformance clause off the extension. + let inheritance = protocols.isEmpty ? "" : ": SwiftRandomKit.RandomGenerable" + + let typeName = type.trimmed.description + let access = accessPrefix(for: declaration) + + let runBody: String + if let structDecl = declaration.as(StructDeclSyntax.self) { + try rejectGenerics(structDecl.genericParameterClause, attribute: node) + runBody = try structRunBody(structDecl, typeName: typeName) + } else if let enumDecl = declaration.as(EnumDeclSyntax.self) { + try rejectGenerics(enumDecl.genericParameterClause, attribute: node) + runBody = try enumRunBody(enumDecl, typeName: typeName, attribute: node) + } else { + throw error(.notAStructOrEnum, at: node) + } + + let extensionDecl: DeclSyntax = """ + extension \(raw: typeName)\(raw: inheritance) { + \(raw: access)static var generator: SwiftRandomKit.AnyRandomGenerator<\(raw: typeName)> { + SwiftRandomKit.AnyRandomGenerator<\(raw: typeName)> { rng in + \(raw: runBody) + } + } + } + """ + return [extensionDecl.cast(ExtensionDeclSyntax.self)] + } + + // MARK: Structs + + private static func structRunBody( + _ structDecl: StructDeclSyntax, + typeName: String + ) throws -> String { + let properties = try storedProperties(of: structDecl) + guard !properties.isEmpty else { + return " \(typeName)()" + } + var lines = [" \(typeName)("] + for (index, property) in properties.enumerated() { + let comma = index == properties.count - 1 ? "" : "," + lines.append(" \(property.name): \(property.generator).run(using: &rng)\(comma)") + } + lines.append(" )") + return lines.joined(separator: "\n") + } + + private struct StoredProperty { + var name: String + /// Source text of the expression that produces this property's generator. + var generator: String + } + + private static func storedProperties(of structDecl: StructDeclSyntax) throws -> [StoredProperty] { + var properties: [StoredProperty] = [] + for member in structDecl.memberBlock.members { + guard let variable = member.decl.as(VariableDeclSyntax.self) else { continue } + let modifiers = variable.modifiers.map(\.name.tokenKind) + if modifiers.contains(.keyword(.static)) || modifiers.contains(.keyword(.class)) + || modifiers.contains(.keyword(.lazy)) { + continue + } + + let genOverride = genAttribute(on: variable) + if genOverride != nil && variable.bindings.count > 1 { + throw error(.genOnMultipleBindings, at: variable) + } + + let isLet = variable.bindingSpecifier.tokenKind == .keyword(.let) + let bindings = Array(variable.bindings) + for (index, binding) in bindings.enumerated() { + // Computed properties have accessors; observers (willSet/didSet) are stored. + if let accessorBlock = binding.accessorBlock { + guard case .accessors(let accessors) = accessorBlock.accessors, + accessors.allSatisfy({ + $0.accessorSpecifier.tokenKind == .keyword(.willSet) + || $0.accessorSpecifier.tokenKind == .keyword(.didSet) + }) + else { continue } + } + // A `let` with a default value is not part of the memberwise initializer. + if isLet && binding.initializer != nil { continue } + + guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else { + throw error(.unsupportedPattern, at: binding) + } + let name = pattern.identifier.text + + if let genOverride { + properties.append(StoredProperty(name: name, generator: "(\(genOverride))")) + continue + } + + guard let type = resolvedType(for: binding, at: index, in: bindings) else { + throw error(.missingTypeAnnotation, at: binding) + } + properties.append(StoredProperty(name: name, generator: "\(typeExpression(type)).generator")) + } + } + return properties + } + + /// The type of a binding, honoring the `var a, b: Int` form where a trailing + /// annotation covers the preceding un-annotated, un-initialized bindings. + private static func resolvedType( + for binding: PatternBindingSyntax, + at index: Int, + in bindings: [PatternBindingSyntax] + ) -> TypeSyntax? { + if let annotation = binding.typeAnnotation { + return annotation.type + } + guard binding.initializer == nil else { return nil } + for later in bindings[(index + 1)...] { + if later.initializer != nil { return nil } + if let annotation = later.typeAnnotation { return annotation.type } + } + return nil + } + + // MARK: Enums + + private static func enumRunBody( + _ enumDecl: EnumDeclSyntax, + typeName: String, + attribute node: AttributeSyntax + ) throws -> String { + let cases = enumDecl.memberBlock.members + .compactMap { $0.decl.as(EnumCaseDeclSyntax.self) } + .flatMap(\.elements) + guard !cases.isEmpty else { + throw error(.enumWithoutCases, at: node) + } + + if cases.count == 1 { + return " return \(caseExpression(cases[0], typeName: typeName))" + } + + var lines = [" switch Swift.Int.random(in: 0...\(cases.count - 1), using: &rng) {"] + for (index, element) in cases.enumerated() { + let label = index == cases.count - 1 ? "default" : "case \(index)" + lines.append(" \(label):") + lines.append(" return \(caseExpression(element, typeName: typeName))") + } + lines.append(" }") + return lines.joined(separator: "\n") + } + + private static func caseExpression(_ element: EnumCaseElementSyntax, typeName: String) -> String { + let caseName = "\(typeName).\(element.name.text)" + guard let parameters = element.parameterClause?.parameters, !parameters.isEmpty else { + return caseName + } + let arguments = parameters.map { parameter -> String in + let value = "\(typeExpression(parameter.type)).generator.run(using: &rng)" + if let label = parameter.firstName, label.tokenKind != .wildcard { + return "\(label.text): \(value)" + } + return value + } + return "\(caseName)(\(arguments.joined(separator: ", ")))" + } + + // MARK: Shared helpers + + /// Spells a type as an expression a `.generator` access can hang off of. + /// `String?` does not parse in expression position, so optional sugar is rewritten + /// to `Swift.Optional`. + private static func typeExpression(_ type: TypeSyntax) -> String { + if let optional = type.as(OptionalTypeSyntax.self) { + return "Swift.Optional<\(optional.wrappedType.trimmed)>" + } + if let unwrapped = type.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) { + return "Swift.Optional<\(unwrapped.wrappedType.trimmed)>" + } + return type.trimmed.description + } + + private static func genAttribute(on variable: VariableDeclSyntax) -> String? { + for attribute in variable.attributes { + guard let attribute = attribute.as(AttributeSyntax.self) else { continue } + let name = attribute.attributeName.trimmed.description + guard name == "Gen" || name == "SwiftRandomKit.Gen" else { continue } + guard case .argumentList(let arguments) = attribute.arguments, + let first = arguments.first + else { continue } + return first.expression.trimmed.description + } + return nil + } + + private static func accessPrefix(for declaration: some DeclGroupSyntax) -> String { + for modifier in declaration.modifiers { + switch modifier.name.tokenKind { + case .keyword(.public), .keyword(.open): + return "public " + case .keyword(.package): + return "package " + default: + continue + } + } + return "" + } + + private static func rejectGenerics( + _ clause: GenericParameterClauseSyntax?, + attribute: AttributeSyntax + ) throws { + if clause != nil { + throw error(.genericType, at: attribute) + } + } + + private static func error( + _ message: RandomGenerableDiagnostic, + at node: some SyntaxProtocol + ) -> DiagnosticsError { + DiagnosticsError(diagnostics: [Diagnostic(node: Syntax(node), message: message)]) + } +} + +/// Implements the `@Gen` marker macro. It expands to nothing: `@RandomGenerable` reads +/// the attribute's argument syntax directly off the property it decorates. +public struct GenMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard declaration.is(VariableDeclSyntax.self) else { + throw DiagnosticsError(diagnostics: [ + Diagnostic(node: Syntax(node), message: RandomGenerableDiagnostic.genNotOnProperty) + ]) + } + return [] + } +} + +enum RandomGenerableDiagnostic: String, DiagnosticMessage { + case notAStructOrEnum + case genericType + case missingTypeAnnotation + case unsupportedPattern + case genOnMultipleBindings + case genNotOnProperty + case enumWithoutCases + + var message: String { + switch self { + case .notAStructOrEnum: + return "@RandomGenerable can only be applied to a struct or an enum" + case .genericType: + return "@RandomGenerable does not support generic types" + case .missingTypeAnnotation: + return "@RandomGenerable requires an explicit type annotation for this property, or a @Gen override" + case .unsupportedPattern: + return "@RandomGenerable does not support tuple or pattern bindings" + case .genOnMultipleBindings: + return "@Gen cannot be applied to a declaration with multiple bindings; declare each property separately" + case .genNotOnProperty: + return "@Gen can only be applied to a stored property" + case .enumWithoutCases: + return "@RandomGenerable cannot derive a generator for an enum with no cases" + } + } + + var severity: DiagnosticSeverity { .error } + + var diagnosticID: MessageID { + MessageID(domain: "SwiftRandomKitMacros", id: rawValue) + } +} diff --git a/Tests/SwiftRandomKitMacrosTests/RandomGenerableMacroTests.swift b/Tests/SwiftRandomKitMacrosTests/RandomGenerableMacroTests.swift new file mode 100644 index 0000000..abb23b0 --- /dev/null +++ b/Tests/SwiftRandomKitMacrosTests/RandomGenerableMacroTests.swift @@ -0,0 +1,404 @@ +#if Derive && os(macOS) +import MacroTesting +import SwiftRandomKitMacros +import Testing + +@Suite( + .macros( + [RandomGenerableMacro.self, GenMacro.self], + record: .failed + ) +) +struct RandomGenerableMacroTests { + @Test func structBasics() { + assertMacro { + """ + @RandomGenerable + struct User { + let name: String + let age: Int + let isAdmin: Bool + } + """ + } expansion: { + """ + struct User { + let name: String + let age: Int + let isAdmin: Bool + } + + extension User { + static var generator: SwiftRandomKit.AnyRandomGenerator { + SwiftRandomKit.AnyRandomGenerator { rng in + User( + name: String.generator.run(using: &rng), + age: Int.generator.run(using: &rng), + isAdmin: Bool.generator.run(using: &rng) + ) + } + } + } + """ + } + } + + @Test func structWithGenOverrides() { + assertMacro { + """ + @RandomGenerable + struct Player { + @Gen(IntGenerator(in: 0...9999)) var score: Int + var alias: String + } + """ + } expansion: { + """ + struct Player { + var score: Int + var alias: String + } + + extension Player { + static var generator: SwiftRandomKit.AnyRandomGenerator { + SwiftRandomKit.AnyRandomGenerator { rng in + Player( + score: (IntGenerator(in: 0 ... 9999)).run(using: &rng), + alias: String.generator.run(using: &rng) + ) + } + } + } + """ + } + } + + @Test func structSkipsNonMemberwiseProperties() { + assertMacro { + """ + @RandomGenerable + struct Config { + let version = 1 + static var shared: Config? = nil + var isOn: Bool + var level: Int { isOn ? 1 : 0 } + var name: String { + didSet { print("renamed") } + } + } + """ + } expansion: { + """ + struct Config { + let version = 1 + static var shared: Config? = nil + var isOn: Bool + var level: Int { isOn ? 1 : 0 } + var name: String { + didSet { print("renamed") } + } + } + + extension Config { + static var generator: SwiftRandomKit.AnyRandomGenerator { + SwiftRandomKit.AnyRandomGenerator { rng in + Config( + isOn: Bool.generator.run(using: &rng), + name: String.generator.run(using: &rng) + ) + } + } + } + """ + } + } + + @Test func structSugaredTypes() { + assertMacro { + """ + @RandomGenerable + struct Profile { + var nickname: String? + var scores: [Int] + } + """ + } expansion: { + """ + struct Profile { + var nickname: String? + var scores: [Int] + } + + extension Profile { + static var generator: SwiftRandomKit.AnyRandomGenerator { + SwiftRandomKit.AnyRandomGenerator { rng in + Profile( + nickname: Swift.Optional.generator.run(using: &rng), + scores: [Int].generator.run(using: &rng) + ) + } + } + } + """ + } + } + + @Test func publicStructGetsPublicGenerator() { + assertMacro { + """ + @RandomGenerable + public struct Token { + public let value: String + } + """ + } expansion: { + """ + public struct Token { + public let value: String + } + + extension Token { + public static var generator: SwiftRandomKit.AnyRandomGenerator { + SwiftRandomKit.AnyRandomGenerator { rng in + Token( + value: String.generator.run(using: &rng) + ) + } + } + } + """ + } + } + + @Test func emptyStruct() { + assertMacro { + """ + @RandomGenerable + struct Marker { + } + """ + } expansion: { + """ + struct Marker { + } + + extension Marker { + static var generator: SwiftRandomKit.AnyRandomGenerator { + SwiftRandomKit.AnyRandomGenerator { rng in + Marker() + } + } + } + """ + } + } + + @Test func multiBindingAnnotation() { + assertMacro { + """ + @RandomGenerable + struct Size { + var width, height: Int + } + """ + } expansion: { + """ + struct Size { + var width, height: Int + } + + extension Size { + static var generator: SwiftRandomKit.AnyRandomGenerator { + SwiftRandomKit.AnyRandomGenerator { rng in + Size( + width: Int.generator.run(using: &rng), + height: Int.generator.run(using: &rng) + ) + } + } + } + """ + } + } + + @Test func enumBasics() { + assertMacro { + """ + @RandomGenerable + enum Reward { + case coins(Int) + case badge(name: String) + case nothing + } + """ + } expansion: { + """ + enum Reward { + case coins(Int) + case badge(name: String) + case nothing + } + + extension Reward { + static var generator: SwiftRandomKit.AnyRandomGenerator { + SwiftRandomKit.AnyRandomGenerator { rng in + switch Swift.Int.random(in: 0 ... 2, using: &rng) { + case 0: + return Reward.coins(Int.generator.run(using: &rng)) + case 1: + return Reward.badge(name: String.generator.run(using: &rng)) + default: + return Reward.nothing + } + } + } + } + """ + } + } + + @Test func singleCaseEnum() { + assertMacro { + """ + @RandomGenerable + enum Only { + case one(Bool) + } + """ + } expansion: { + """ + enum Only { + case one(Bool) + } + + extension Only { + static var generator: SwiftRandomKit.AnyRandomGenerator { + SwiftRandomKit.AnyRandomGenerator { rng in + return Only.one(Bool.generator.run(using: &rng)) + } + } + } + """ + } + } + + // MARK: Diagnostics + + @Test func classIsRejected() { + assertMacro { + """ + @RandomGenerable + class Service { + var id: Int + } + """ + } diagnostics: { + """ + @RandomGenerable + ┬─────────────── + ╰─ 🛑 @RandomGenerable can only be applied to a struct or an enum + class Service { + var id: Int + } + """ + } + } + + @Test func genericStructIsRejected() { + assertMacro { + """ + @RandomGenerable + struct Box { + var value: Value + } + """ + } diagnostics: { + """ + @RandomGenerable + ┬─────────────── + ╰─ 🛑 @RandomGenerable does not support generic types + struct Box { + var value: Value + } + """ + } + } + + @Test func missingTypeAnnotationIsRejected() { + assertMacro { + """ + @RandomGenerable + struct Counter { + var count = 0 + } + """ + } diagnostics: { + """ + @RandomGenerable + struct Counter { + var count = 0 + ┬──────── + ╰─ 🛑 @RandomGenerable requires an explicit type annotation for this property, or a @Gen override + } + """ + } + } + + @Test func genOnMultipleBindingsIsRejected() { + assertMacro { + """ + @RandomGenerable + struct Size { + @Gen(IntGenerator(in: 1...100)) var width, height: Int + } + """ + } diagnostics: { + """ + @RandomGenerable + struct Size { + @Gen(IntGenerator(in: 1...100)) var width, height: Int + ┬───────────────────────────────────────────────────── + ├─ 🛑 peer macro can only be applied to a single variable + ╰─ 🛑 @Gen cannot be applied to a declaration with multiple bindings; declare each property separately + } + """ + } + } + + @Test func enumWithoutCasesIsRejected() { + assertMacro { + """ + @RandomGenerable + enum Never2 { + } + """ + } diagnostics: { + """ + @RandomGenerable + ┬─────────────── + ╰─ 🛑 @RandomGenerable cannot derive a generator for an enum with no cases + enum Never2 { + } + """ + } + } + + @Test func genOnFunctionIsRejected() { + assertMacro { + """ + struct S { + @Gen(BoolGenerator()) func flip() {} + } + """ + } diagnostics: { + """ + struct S { + @Gen(BoolGenerator()) func flip() {} + ┬──────────────────── + ╰─ 🛑 @Gen can only be applied to a stored property + } + """ + } + } +} +#endif diff --git a/Tests/SwiftRandomKitTests/DerivedGeneratorTests.swift b/Tests/SwiftRandomKitTests/DerivedGeneratorTests.swift new file mode 100644 index 0000000..ce5f3d5 --- /dev/null +++ b/Tests/SwiftRandomKitTests/DerivedGeneratorTests.swift @@ -0,0 +1,85 @@ +#if Derive +import Testing +import SwiftRandomKit + +@RandomGenerable +private struct Fixture: Equatable { + @Gen(IntGenerator(in: 18...99)) + var age: Int + var name: String + var isActive: Bool + var nickname: String? + let version = 1 +} + +@RandomGenerable +private struct Nested: Equatable { + var fixture: Fixture + @Gen(FloatGenerator(in: 0...1)) + var weight: Double +} + +@RandomGenerable +private enum Weather: Equatable { + case sunny + case cloudy(coverage: Double) + case windy(Int) +} + +struct DerivedGeneratorTests { + @Test func sameSeedProducesSameFixture() { + var a = LCRNG(seed: 42) + var b = LCRNG(seed: 42) + #expect(Fixture.generator.run(using: &a) == Fixture.generator.run(using: &b)) + } + + @Test func genOverrideIsRespected() { + var rng = LCRNG(seed: 1) + for _ in 1...200 { + let fixture = Fixture.generator.run(using: &rng) + #expect((18...99).contains(fixture.age)) + #expect(fixture.version == 1) + } + } + + @Test func derivedStructUsesTypeDefaults() { + var rng = LCRNG(seed: 2) + let fixtures = Fixture.generator.array(100).run(using: &rng) + #expect(fixtures.allSatisfy { $0.name.count == 8 }) + // The Optional default should produce both nil and non-nil over 100 runs. + #expect(fixtures.contains { $0.nickname == nil }) + #expect(fixtures.contains { $0.nickname != nil }) + } + + @Test func derivedTypesNest() { + var rng = LCRNG(seed: 3) + let nested = Nested.generator.run(using: &rng) + #expect((18...99).contains(nested.fixture.age)) + #expect((0.0...1.0).contains(nested.weight)) + } + + @Test func derivedEnumCoversAllCases() { + var rng = LCRNG(seed: 4) + var sawSunny = false + var sawCloudy = false + var sawWindy = false + for _ in 1...300 { + switch Weather.generator.run(using: &rng) { + case .sunny: sawSunny = true + case .cloudy: sawCloudy = true + case .windy: sawWindy = true + } + } + #expect(sawSunny && sawCloudy && sawWindy) + } + + @Test func derivedGeneratorComposesWithCombinators() { + var rng = LCRNG(seed: 5) + let adults = Fixture.generator + .filter { $0.age >= 50 } + .array(10) + .run(using: &rng) + #expect(adults.allSatisfy { $0.age >= 50 }) + } +} +#endif diff --git a/Tests/SwiftRandomKitTests/RandomGenerableTests.swift b/Tests/SwiftRandomKitTests/RandomGenerableTests.swift new file mode 100644 index 0000000..90088ce --- /dev/null +++ b/Tests/SwiftRandomKitTests/RandomGenerableTests.swift @@ -0,0 +1,72 @@ +import Testing +import SwiftRandomKit + +struct RandomGenerableTests { + @Test func integerDefaultsAreDeterministic() { + var a = LCRNG(seed: 1) + var b = LCRNG(seed: 1) + #expect(Int.generator.run(using: &a) == Int.generator.run(using: &b)) + #expect(UInt8.generator.run(using: &a) == UInt8.generator.run(using: &b)) + } + + @Test func floatDefaultStaysInUnitInterval() { + var rng = LCRNG(seed: 2) + for _ in 1...100 { + let value = Double.generator.run(using: &rng) + #expect((0.0...1.0).contains(value)) + } + } + + @Test func boolDefaultProducesBothValues() { + var rng = LCRNG(seed: 3) + let flips = Bool.generator.array(100).run(using: &rng) + #expect(flips.contains(true)) + #expect(flips.contains(false)) + } + + @Test func stringDefaultIsEightAlphanumerics() { + var rng = LCRNG(seed: 4) + for _ in 1...20 { + let value = String.generator.run(using: &rng) + #expect(value.count == 8) + #expect(value.allSatisfy { $0.isLetter || $0.isNumber }) + } + } + + @Test func characterDefaultIsLetter() { + var rng = LCRNG(seed: 5) + for _ in 1...50 { + #expect(Character.generator.run(using: &rng).isLetter) + } + } + + @Test func optionalDefaultMixesNilAndValues() { + var rng = LCRNG(seed: 6) + let values = Optional.generator.array(100).run(using: &rng) + let nilCount = values.filter { $0 == nil }.count + #expect((20...80).contains(nilCount)) + } + + @Test func arrayDefaultCountWithinBounds() { + var rng = LCRNG(seed: 7) + for _ in 1...50 { + let count = [Bool].generator.run(using: &rng).count + #expect((0...10).contains(count)) + } + } + + @Test func manualConformanceComposes() { + struct Temperature: RandomGenerable, Equatable { + var celsius: Double + + static var generator: some RandomGenerator { + FloatGenerator(in: -40...50).map(Temperature.init) + } + } + + var rng = LCRNG(seed: 8) + let sample = Temperature.generator.array(10).run(using: &rng) + #expect(sample.count == 10) + #expect(sample.allSatisfy { (-40.0...50.0).contains($0.celsius) }) + } +}