Skip to content

Add AutoAddonPlugin to detect @LiveElement views in a project #1553

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down
71 changes: 71 additions & 0 deletions Plugins/AutoAddonPlugin/AutoAddonPlugin.swift
Original file line number Diff line number Diff line change
@@ -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
6 changes: 4 additions & 2 deletions Plugins/ModifierGeneratorPlugin/ModifierGeneratorPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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"],
Comment on lines -26 to +28
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be reverted if/when SDKROOT is added back to the environment

inputFiles: [],
outputFiles: [output]
)
Expand Down
166 changes: 166 additions & 0 deletions Sources/AutoAddon/AutoAddon.swift
Original file line number Diff line number Diff line change
@@ -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<String>()

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
}
}
}
Loading