diff --git a/CHANGELOG.md b/CHANGELOG.md index 42d593551..6f881a349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] + +### Added + +- `AutoAddonPlugin` can be added to your Xcode project to generate an addon from the `@LiveElement` Views in a target (#1553) + +### Changed + +### Removed + +### Fixed + ## [0.4.0] ### Added diff --git a/Package.swift b/Package.swift index a73f05f4a..3f42b85be 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,8 @@ let package = Package( .library( name: "LiveViewNativeStylesheet", targets: ["LiveViewNativeStylesheet"]), - .executable(name: "ModifierGenerator", targets: ["ModifierGenerator"]) + .executable(name: "ModifierGenerator", targets: ["ModifierGenerator"]), + .plugin(name: "AutoAddonPlugin", targets: ["AutoAddonPlugin"]) ], dependencies: [ // Dependencies declare other packages that this package depends on. @@ -96,6 +97,21 @@ let package = Package( dependencies: [] ), + .executableTarget( + name: "AutoAddon", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax") + ] + ), + .plugin( + name: "AutoAddonPlugin", + capability: .buildTool, + dependencies: ["AutoAddon"] + ), + // Macros .macro( name: "LiveViewNativeMacros", diff --git a/Plugins/AutoAddonPlugin/AutoAddonPlugin.swift b/Plugins/AutoAddonPlugin/AutoAddonPlugin.swift new file mode 100644 index 000000000..a11ac574d --- /dev/null +++ b/Plugins/AutoAddonPlugin/AutoAddonPlugin.swift @@ -0,0 +1,71 @@ +// +// AppAddonPlugin.swift +// LiveViewNative +// +// Created by Carson Katri on 3/6/25. +// + +import PackagePlugin +import Foundation + +@main +struct AutoAddonPlugin: BuildToolPlugin { + func createBuildCommands(context: PluginContext, target: any Target) async throws -> [Command] { + let tool = try context.tool(named: "AutoAddon") + let inputFiles = target.sourceModule?.sourceFiles + .filter({ + (try? String(contentsOf: $0.url, encoding: .utf8))? + .contains("@LiveElement") + ?? false + }) + .map(\.url) + ?? [] + let outputFile = context.pluginWorkDirectoryURL.appending(path: "AutoAddon.swift") + + return [ + .buildCommand( + displayName: "Auto Addon", + executable: tool.url, + arguments: [ + target.name, + outputFile.absoluteString + ] + inputFiles.map(\.absoluteString), + environment: [:], + inputFiles: inputFiles, + outputFiles: [outputFile] + ) + ] + } +} + +#if canImport(XcodeProjectPlugin) +import XcodeProjectPlugin + +extension AutoAddonPlugin: XcodeBuildToolPlugin { + func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] { + let tool = try context.tool(named: "AutoAddon") + let inputFiles = target.inputFiles + .filter({ + (try? String(contentsOf: $0.url, encoding: .utf8))? + .contains("@LiveElement") + ?? false + }) + .map(\.url) + let outputFile = context.pluginWorkDirectoryURL.appending(path: "AutoAddon.swift") + + return [ + .buildCommand( + displayName: "Auto Addon", + executable: tool.url, + arguments: [ + target.displayName, + outputFile.absoluteString + ] + inputFiles.map(\.absoluteString), + environment: [:], + inputFiles: inputFiles, + outputFiles: [outputFile] + ) + ] + } +} +#endif diff --git a/Plugins/ModifierGeneratorPlugin/ModifierGeneratorPlugin.swift b/Plugins/ModifierGeneratorPlugin/ModifierGeneratorPlugin.swift index acaa7a48a..e8807e54f 100644 --- a/Plugins/ModifierGeneratorPlugin/ModifierGeneratorPlugin.swift +++ b/Plugins/ModifierGeneratorPlugin/ModifierGeneratorPlugin.swift @@ -11,7 +11,6 @@ import Foundation @main struct ModifierGeneratorPlugin: BuildToolPlugin { func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { - guard let target = target as? SwiftSourceModuleTarget else { return [] } let tool = try context.tool(named: "ModifierGenerator") let output = context.pluginWorkDirectoryURL.appending(path: "GeneratedModifiers.swift") @@ -23,7 +22,10 @@ struct ModifierGeneratorPlugin: BuildToolPlugin { displayName: tool.name, executable: tool.url, arguments: arguments, - environment: ProcessInfo.processInfo.environment, +// environment: ProcessInfo.processInfo.environment, +// environment: ["SDKROOT": "/Applications/Xcode-16-3.app/Contents/Developer/Platforms/WatchSimulator.platform/Developer/SDKs/WatchSimulator.sdk"], +// environment: ["SDKROOT": "/Applications/Xcode-16-3.app/Contents/Developer/Platforms/AppleTVSimulator.platform/Developer/SDKs/AppleTVSimulator.sdk"], + environment: ["SDKROOT": "/Applications/Xcode-16-3.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.4.sdk"], inputFiles: [], outputFiles: [output] ) diff --git a/Sources/AutoAddon/AutoAddon.swift b/Sources/AutoAddon/AutoAddon.swift new file mode 100644 index 000000000..ba676d87a --- /dev/null +++ b/Sources/AutoAddon/AutoAddon.swift @@ -0,0 +1,166 @@ +// +// AutoAddon.swift +// LiveViewNative +// +// Created by Carson Katri on 3/6/25. +// + +import ArgumentParser +import Foundation +import SwiftSyntax +import SwiftParser +import SwiftSyntaxBuilder + +@main +struct AutoAddon: ParsableCommand { + @Argument private var name: String + @Argument private var outputFile: String + @Argument private var inputFiles: [String] + + func run() throws { + let visitor = LiveElementVisitor(viewMode: .fixedUp) + + for inputFile in self.inputFiles { + guard let inputFile = URL(string: inputFile) + else { continue } + let source = try String(contentsOf: inputFile, encoding: .utf8) + let sourceFile = Parser.parse(source: source) + visitor.walk(sourceFile) + } + + /// The `TagName` enum required by `CustomRegistry`. + let tagNameDecl = EnumDeclSyntax( + modifiers: [ + DeclModifierSyntax(name: .keyword(.public)) + ], + name: .identifier("TagName"), + inheritanceClause: InheritanceClauseSyntax { + InheritedTypeSyntax(type: IdentifierTypeSyntax(name: .identifier("String"))) + } + ) { + EnumCaseDeclSyntax { + for liveElement in visitor.liveElements { + EnumCaseElementSyntax(name: .identifier(liveElement)) + } + } + } + + /// static `lookup` function required by `CustomRegistry`. + /// ``` + /// static func lookup(_ name: TagName, element: ElementNode) -> some View + /// ``` + let lookupDecl = FunctionDeclSyntax( + attributes: [ + .attribute(AttributeSyntax(attributeName: IdentifierTypeSyntax(name: .identifier("MainActor")))), + .attribute(AttributeSyntax(attributeName: IdentifierTypeSyntax(name: .identifier("ViewBuilder")))), + ], + modifiers: [ + DeclModifierSyntax(name: .keyword(.public)), + DeclModifierSyntax(name: .keyword(.static)) + ], + name: .identifier("lookup"), + signature: FunctionSignatureSyntax( + parameterClause: FunctionParameterClauseSyntax { + FunctionParameterSyntax( + firstName: .wildcardToken(), + secondName: .identifier("name"), + type: IdentifierTypeSyntax(name: .identifier("TagName")) + ) + FunctionParameterSyntax( + firstName: .identifier("element"), + type: IdentifierTypeSyntax(name: .identifier("ElementNode")) + ) + }, + returnClause: ReturnClauseSyntax( + type: SomeOrAnyTypeSyntax( + someOrAnySpecifier: .keyword(.some), + constraint: IdentifierTypeSyntax(name: .identifier("View")) + ) + ) + ) + ) { + SwitchExprSyntax(subject: DeclReferenceExprSyntax(baseName: .identifier("name"))) { + for liveElement in visitor.liveElements { + SwitchCaseSyntax(label: .case(SwitchCaseLabelSyntax { + SwitchCaseItemSyntax(pattern: ExpressionPatternSyntax( + expression: MemberAccessExprSyntax(name: .identifier(liveElement)) + )) + })) { + // create the matching View for the TagName + FunctionCallExprSyntax( + callee: TypeExprSyntax( + type: IdentifierTypeSyntax( + name: .identifier(liveElement), + genericArgumentClause: GenericArgumentClauseSyntax { + GenericArgumentSyntax(argument: IdentifierTypeSyntax(name: .identifier("Root"))) + } + ) + ) + ) + } + } + } + } + + /// Addon struct declaration using the `@Addon` macro in an extension of `Addons`. + let addonDecl = ExtensionDeclSyntax( + modifiers: [DeclModifierSyntax(name: .keyword(.public))], + extendedType: IdentifierTypeSyntax(name: .identifier("Addons")) + ) { + StructDeclSyntax( + attributes: [.attribute(AttributeSyntax(attributeName: IdentifierTypeSyntax(name: .identifier("Addon"))))], + name: .identifier(name), + genericParameterClause: GenericParameterClauseSyntax { + GenericParameterSyntax( + name: .identifier("Root"), + colon: .colonToken(), + inheritedType: IdentifierTypeSyntax(name: .identifier("RootRegistry")) + ) + } + ) { + tagNameDecl + lookupDecl + } + } + + try SourceFileSyntax { + ImportDeclSyntax(path: [ImportPathComponentSyntax(name: .identifier("SwiftUI"))]) + ImportDeclSyntax(path: [ImportPathComponentSyntax(name: .identifier("LiveViewNative"))]) + + addonDecl + } + .formatted() + .description + .write(to: URL(string: outputFile)!, atomically: true, encoding: .utf8) + } +} + +final class LiveElementVisitor: SyntaxVisitor { + var liveElements = Set() + + override func visit(_ decl: StructDeclSyntax) -> SyntaxVisitorContinueKind { + guard decl.attributes.contains(where: { + guard case let .attribute(attribute) = $0 + else { return false } + return attribute.attributeName.isLiveElementMacro + }) + else { return .visitChildren } + + liveElements.insert(decl.name.text) + + return .visitChildren + } +} + +extension TypeSyntax { + var isLiveElementMacro: Bool { + if let identifierType = self.as(IdentifierTypeSyntax.self) { + return identifierType.name.text == "LiveElement" + } else if let memberType = self.as(MemberTypeSyntax.self) { + return memberType.baseType.as(IdentifierTypeSyntax.self)?.name.text == "" + && memberType.name.text == "LiveElement" + } else { + return false + } + } +}