Skip to content
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import PackageDescription

let package = Package(
name: "OversizeArchitecture",
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)],
platforms: [.macOS(.v14), .iOS(.v16), .tvOS(.v16), .watchOS(.v10)],
products: [
.library(
name: "OversizeArchitecture",
Expand Down
126 changes: 126 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# OversizeArchitecture

Swift architectural package with macros for automatic MVVM code generation.

## Macros

### @ViewModel

Automatically generates ViewModelProtocol components:

```swift
@ViewModel(module: ProductList.self)
public actor ProductListViewModel: ViewModelProtocol {
func onRefresh() {
// refresh logic
}

func onFilterSelected(filter: FilterType) {
// filter logic
}
}
```

Generates:
- Typealiases for Input, Output, ViewState
- Properties: state, input, output
- Initializer
- handleAction method
- Action enum with cases based on methods with "on" prefix

### @View

Generates View components:

```swift
@View(module: ProductList.self)
public struct ProductListView: ViewProtocol {
public var body: some View {
VStack {
Text(viewState.title)
Button("Refresh") {
reducer(.onRefresh)
}
}
}
}
```

Generates:
- viewState property
- reducer property
- Initializer

### @Module

Generates Module typealiases:

```swift
@Module
public enum ProductList: ModuleProtocol {
}
```

Generates:
- typealias Input = ProductListInput
- typealias Output = ProductListOutput
- typealias ViewState = ProductListViewState
- typealias ViewModel = ProductListViewModel
- typealias ViewScene = ProductListView

## Module Structure

Each module consists of:

1. **Input** - input parameters
2. **Output** - callbacks
3. **ViewState** - UI state
4. **ViewModel** - business logic
5. **View** - UI component
6. **Module** - entry point

## Complete Module Example

```swift
// ProductListModule.swift
public struct ProductListInput: Sendable {
public let filterType: FilterType
// ...
}

public struct ProductListOutput: Sendable {
public let onSelection: (Product) -> Void
// ...
}

@Module
public enum ProductList: ModuleProtocol {
}

// ProductListViewState.swift
@Observable
public final class ProductListViewState: ViewStateProtocol {
// UI state
}

// ProductListViewModel.swift
@ViewModel(module: ProductList.self)
public actor ProductListViewModel: ViewModelProtocol {
func onRefresh() { }
func onFilterSelected(filter: FilterType) { }
}

// ProductListView.swift
@View(module: ProductList.self)
public struct ProductListView: ViewProtocol {
public var body: some View {
// UI code
}
}
```

## Requirements

- Swift 6.0+
- iOS 17.0+
- macOS 14.0+
28 changes: 28 additions & 0 deletions Sources/OversizeArchitecture/Callback.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// Copyright © 2025 Alexander Romanov
// Callback.swift, created on 17.09.2025
//

import Foundation

public struct Callback<Value>: Hashable, Equatable {
public let identifier: String
public let handler: (Value) -> Void

public init(_ identifier: String = UUID().uuidString, handler: @escaping (Value) -> Void) {
self.identifier = identifier
self.handler = handler
}

public func callAsFunction(_ value: Value) {
handler(value)
}

public func hash(into hasher: inout Hasher) {
hasher.combine(identifier)
}

public static func == (lhs: Callback<Value>, rhs: Callback<Value>) -> Bool {
lhs.identifier == rhs.identifier
}
}
10 changes: 8 additions & 2 deletions Sources/OversizeArchitecture/Macros.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,11 @@
//

@attached(extension, names: named(Action))
@attached(member, names: named(handleAction))
public macro ViewModel() = #externalMacro(module: "OversizeArchitectureMacros", type: "ViewModelMacro")
@attached(member, names: named(handleAction), named(state), named(input), named(output), named(init))
public macro ViewModel(module: Any.Type? = nil) = #externalMacro(module: "OversizeArchitectureMacros", type: "ViewModelMacro")

@attached(member, names: named(viewState), named(reducer), named(init))
public macro View(module: Any.Type) = #externalMacro(module: "OversizeArchitectureMacros", type: "ViewMacro")

@attached(member, names: named(Input), named(Output), named(ViewState), named(ViewModel), named(ViewScene))
public macro Module(prefix: String? = nil) = #externalMacro(module: "OversizeArchitectureMacros", type: "ModuleMacro")
28 changes: 28 additions & 0 deletions Sources/OversizeArchitecture/Protocols/ModuleProtocol.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// Copyright © 2025 Alexander Romanov
// ModuleProtocol.swift, created on 17.09.2025
//

import SwiftUI

public protocol ModuleProtocol {
associatedtype Input
associatedtype Output
associatedtype ViewState: ViewStateProtocol where ViewState.Input == Input
associatedtype ViewScene: ViewProtocol where ViewScene.ViewState == ViewState, ViewScene.ViewModel == ViewModel
associatedtype ViewModel: ViewModelProtocol where ViewModel.Input == Input, ViewModel.Output == Output, ViewModel.ViewState == ViewState
}

public extension ModuleProtocol where ViewScene: View {
@MainActor
static func build(input: Input? = nil, output: Output? = nil) -> some View {
let state = ViewState(input: input)
let viewModel = ViewModel(
state: state,
input: input,
output: output
)
let reducer = Reducer(viewModel: viewModel)
return ViewScene(viewState: state, reducer: reducer)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
//

public protocol ViewModelProtocol: Sendable {
associatedtype Input: Sendable
associatedtype Output: Sendable
associatedtype Action: Sendable
associatedtype ViewState: ViewStateProtocol

@MainActor
init(state: ViewState)
init(state: ViewState, input: Input?, output: Output?)

func handleAction(_ action: Action) async
}
1 change: 0 additions & 1 deletion Sources/OversizeArchitecture/Protocols/ViewProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,5 @@ public protocol ViewProtocol: View {
associatedtype ViewState: ViewStateProtocol
associatedtype ViewModel: ViewModelProtocol

@MainActor
init(viewState: ViewState, reducer: Reducer<ViewModel>)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
//

@MainActor
public protocol ViewStateProtocol: Sendable {}
public protocol ViewStateProtocol: Sendable {
associatedtype Input

init(input: Input?)
}

public extension ViewStateProtocol {
func update(_ handler: @Sendable @MainActor (Self) -> Void) async {
Expand Down
107 changes: 107 additions & 0 deletions Sources/OversizeArchitectureMacros/Macros/ModuleMacro.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//
// Copyright © 2025 Alexander Romanov
// ModuleMacro.swift, created on 18.09.2025
//

import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct ModuleMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard declaration.as(EnumDeclSyntax.self) != nil else {
throw ModuleMacroError.onlyApplicableToEnum
}

let prefix = extractPrefix(from: node) ?? extractPrefixFromName(declaration: declaration)

let members = generateModuleTypealiases(prefix: prefix)
return members
}
}

enum ModuleMacroError: Error, CustomStringConvertible {
case onlyApplicableToEnum

var description: String {
switch self {
case .onlyApplicableToEnum:
"@Module can only be applied to enums"
}
}
}

private extension ModuleMacro {
static func extractPrefix(from node: AttributeSyntax) -> String? {
guard let arguments = node.arguments?.as(LabeledExprListSyntax.self) else {
return nil
}

for argument in arguments {
if argument.label?.text == "prefix" {
if let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self) {
return stringLiteral.segments.first?.as(StringSegmentSyntax.self)?.content.text
}
}
}
return nil
}

static func extractPrefixFromName(declaration: some DeclGroupSyntax) -> String {
guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
return "Unknown"
}

let fullName = enumDecl.name.text
// Remove "Module" suffix if present
if fullName.hasSuffix("Module") {
return String(fullName.dropLast(6))
}
return fullName
}

static func generateModuleTypealiases(prefix: String) -> [DeclSyntax] {
[
DeclSyntax(
try! TypeAliasDeclSyntax(
"""
public typealias Input = \(raw: prefix)Input
"""
)
),
DeclSyntax(
try! TypeAliasDeclSyntax(
"""
public typealias Output = \(raw: prefix)Output
"""
)
),
DeclSyntax(
try! TypeAliasDeclSyntax(
"""
public typealias ViewState = \(raw: prefix)ViewState
"""
)
),
DeclSyntax(
try! TypeAliasDeclSyntax(
"""
public typealias ViewModel = \(raw: prefix)ViewModel
"""
)
),
DeclSyntax(
try! TypeAliasDeclSyntax(
"""
public typealias ViewScene = \(raw: prefix)View
"""
)
)
]
}
}
Loading