Skip to content

Commit 01c3aa3

Browse files
committed
Add AutoAddonPlugin to detect @LiveElement views in a project
1 parent 132c5c9 commit 01c3aa3

File tree

4 files changed

+258
-3
lines changed

4 files changed

+258
-3
lines changed

Package.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ let package = Package(
2020
.library(
2121
name: "LiveViewNativeStylesheet",
2222
targets: ["LiveViewNativeStylesheet"]),
23-
.executable(name: "ModifierGenerator", targets: ["ModifierGenerator"])
23+
.executable(name: "ModifierGenerator", targets: ["ModifierGenerator"]),
24+
.plugin(name: "AutoAddonPlugin", targets: ["AutoAddonPlugin"])
2425
],
2526
dependencies: [
2627
// Dependencies declare other packages that this package depends on.
@@ -96,6 +97,21 @@ let package = Package(
9697
dependencies: []
9798
),
9899

100+
.executableTarget(
101+
name: "AutoAddon",
102+
dependencies: [
103+
.product(name: "ArgumentParser", package: "swift-argument-parser"),
104+
.product(name: "SwiftSyntax", package: "swift-syntax"),
105+
.product(name: "SwiftParser", package: "swift-syntax"),
106+
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax")
107+
]
108+
),
109+
.plugin(
110+
name: "AutoAddonPlugin",
111+
capability: .buildTool,
112+
dependencies: ["AutoAddon"]
113+
),
114+
99115
// Macros
100116
.macro(
101117
name: "LiveViewNativeMacros",
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//
2+
// AppAddonPlugin.swift
3+
// LiveViewNative
4+
//
5+
// Created by Carson Katri on 3/6/25.
6+
//
7+
8+
import PackagePlugin
9+
import Foundation
10+
11+
@main
12+
struct AutoAddonPlugin: BuildToolPlugin {
13+
func createBuildCommands(context: PluginContext, target: any Target) async throws -> [Command] {
14+
let tool = try context.tool(named: "AutoAddon")
15+
let inputFiles = target.sourceModule?.sourceFiles
16+
.filter({
17+
(try? String(contentsOf: $0.url, encoding: .utf8))?
18+
.contains("@LiveElement")
19+
?? false
20+
})
21+
.map(\.url)
22+
?? []
23+
let outputFile = context.pluginWorkDirectoryURL.appending(path: "AutoAddon.swift")
24+
25+
return [
26+
.buildCommand(
27+
displayName: "Auto Addon",
28+
executable: tool.url,
29+
arguments: [
30+
target.name,
31+
outputFile.absoluteString
32+
] + inputFiles.map(\.absoluteString),
33+
environment: [:],
34+
inputFiles: inputFiles,
35+
outputFiles: [outputFile]
36+
)
37+
]
38+
}
39+
}
40+
41+
#if canImport(XcodeProjectPlugin)
42+
import XcodeProjectPlugin
43+
44+
extension AutoAddonPlugin: XcodeBuildToolPlugin {
45+
func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] {
46+
let tool = try context.tool(named: "AutoAddon")
47+
let inputFiles = target.inputFiles
48+
.filter({
49+
(try? String(contentsOf: $0.url, encoding: .utf8))?
50+
.contains("@LiveElement")
51+
?? false
52+
})
53+
.map(\.url)
54+
let outputFile = context.pluginWorkDirectoryURL.appending(path: "AutoAddon.swift")
55+
56+
return [
57+
.buildCommand(
58+
displayName: "Auto Addon",
59+
executable: tool.url,
60+
arguments: [
61+
target.displayName,
62+
outputFile.absoluteString
63+
] + inputFiles.map(\.absoluteString),
64+
environment: [:],
65+
inputFiles: inputFiles,
66+
outputFiles: [outputFile]
67+
)
68+
]
69+
}
70+
}
71+
#endif

Plugins/ModifierGeneratorPlugin/ModifierGeneratorPlugin.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import Foundation
1111
@main
1212
struct ModifierGeneratorPlugin: BuildToolPlugin {
1313
func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
14-
guard let target = target as? SwiftSourceModuleTarget else { return [] }
1514
let tool = try context.tool(named: "ModifierGenerator")
1615
let output = context.pluginWorkDirectoryURL.appending(path: "GeneratedModifiers.swift")
1716

@@ -23,7 +22,10 @@ struct ModifierGeneratorPlugin: BuildToolPlugin {
2322
displayName: tool.name,
2423
executable: tool.url,
2524
arguments: arguments,
26-
environment: ProcessInfo.processInfo.environment,
25+
// environment: ProcessInfo.processInfo.environment,
26+
// environment: ["SDKROOT": "/Applications/Xcode-16-3.app/Contents/Developer/Platforms/WatchSimulator.platform/Developer/SDKs/WatchSimulator.sdk"],
27+
// environment: ["SDKROOT": "/Applications/Xcode-16-3.app/Contents/Developer/Platforms/AppleTVSimulator.platform/Developer/SDKs/AppleTVSimulator.sdk"],
28+
environment: ["SDKROOT": "/Applications/Xcode-16-3.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.4.sdk"],
2729
inputFiles: [],
2830
outputFiles: [output]
2931
)

Sources/AutoAddon/AutoAddon.swift

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
//
2+
// AutoAddon.swift
3+
// LiveViewNative
4+
//
5+
// Created by Carson Katri on 3/6/25.
6+
//
7+
8+
import ArgumentParser
9+
import Foundation
10+
import SwiftSyntax
11+
import SwiftParser
12+
import SwiftSyntaxBuilder
13+
14+
@main
15+
struct AutoAddon: ParsableCommand {
16+
@Argument private var name: String
17+
@Argument private var outputFile: String
18+
@Argument private var inputFiles: [String]
19+
20+
func run() throws {
21+
let visitor = LiveElementVisitor(viewMode: .fixedUp)
22+
23+
for inputFile in self.inputFiles {
24+
guard let inputFile = URL(string: inputFile)
25+
else { continue }
26+
let source = try String(contentsOf: inputFile, encoding: .utf8)
27+
let sourceFile = Parser.parse(source: source)
28+
visitor.walk(sourceFile)
29+
}
30+
31+
/// The `TagName` enum required by `CustomRegistry`.
32+
let tagNameDecl = EnumDeclSyntax(
33+
modifiers: [
34+
DeclModifierSyntax(name: .keyword(.public))
35+
],
36+
name: .identifier("TagName"),
37+
inheritanceClause: InheritanceClauseSyntax {
38+
InheritedTypeSyntax(type: IdentifierTypeSyntax(name: .identifier("String")))
39+
}
40+
) {
41+
EnumCaseDeclSyntax {
42+
for liveElement in visitor.liveElements {
43+
EnumCaseElementSyntax(name: .identifier(liveElement))
44+
}
45+
}
46+
}
47+
48+
/// static `lookup` function required by `CustomRegistry`.
49+
/// ```
50+
/// static func lookup(_ name: TagName, element: ElementNode) -> some View
51+
/// ```
52+
let lookupDecl = FunctionDeclSyntax(
53+
attributes: [
54+
.attribute(AttributeSyntax(attributeName: IdentifierTypeSyntax(name: .identifier("MainActor")))),
55+
.attribute(AttributeSyntax(attributeName: IdentifierTypeSyntax(name: .identifier("ViewBuilder")))),
56+
],
57+
modifiers: [
58+
DeclModifierSyntax(name: .keyword(.public)),
59+
DeclModifierSyntax(name: .keyword(.static))
60+
],
61+
name: .identifier("lookup"),
62+
signature: FunctionSignatureSyntax(
63+
parameterClause: FunctionParameterClauseSyntax {
64+
FunctionParameterSyntax(
65+
firstName: .wildcardToken(),
66+
secondName: .identifier("name"),
67+
type: IdentifierTypeSyntax(name: .identifier("TagName"))
68+
)
69+
FunctionParameterSyntax(
70+
firstName: .identifier("element"),
71+
type: IdentifierTypeSyntax(name: .identifier("ElementNode"))
72+
)
73+
},
74+
returnClause: ReturnClauseSyntax(
75+
type: SomeOrAnyTypeSyntax(
76+
someOrAnySpecifier: .keyword(.some),
77+
constraint: IdentifierTypeSyntax(name: .identifier("View"))
78+
)
79+
)
80+
)
81+
) {
82+
SwitchExprSyntax(subject: DeclReferenceExprSyntax(baseName: .identifier("name"))) {
83+
for liveElement in visitor.liveElements {
84+
SwitchCaseSyntax(label: .case(SwitchCaseLabelSyntax {
85+
SwitchCaseItemSyntax(pattern: ExpressionPatternSyntax(
86+
expression: MemberAccessExprSyntax(name: .identifier(liveElement))
87+
))
88+
})) {
89+
// create the matching View for the TagName
90+
FunctionCallExprSyntax(
91+
callee: TypeExprSyntax(
92+
type: IdentifierTypeSyntax(
93+
name: .identifier(liveElement),
94+
genericArgumentClause: GenericArgumentClauseSyntax {
95+
GenericArgumentSyntax(argument: IdentifierTypeSyntax(name: .identifier("Root")))
96+
}
97+
)
98+
)
99+
)
100+
}
101+
}
102+
}
103+
}
104+
105+
/// Addon struct declaration using the `@Addon` macro in an extension of `Addons`.
106+
let addonDecl = ExtensionDeclSyntax(
107+
modifiers: [DeclModifierSyntax(name: .keyword(.public))],
108+
extendedType: IdentifierTypeSyntax(name: .identifier("Addons"))
109+
) {
110+
StructDeclSyntax(
111+
attributes: [.attribute(AttributeSyntax(attributeName: IdentifierTypeSyntax(name: .identifier("Addon"))))],
112+
name: .identifier(name),
113+
genericParameterClause: GenericParameterClauseSyntax {
114+
GenericParameterSyntax(
115+
name: .identifier("Root"),
116+
colon: .colonToken(),
117+
inheritedType: IdentifierTypeSyntax(name: .identifier("RootRegistry"))
118+
)
119+
}
120+
) {
121+
tagNameDecl
122+
lookupDecl
123+
}
124+
}
125+
126+
try SourceFileSyntax {
127+
ImportDeclSyntax(path: [ImportPathComponentSyntax(name: .identifier("SwiftUI"))])
128+
ImportDeclSyntax(path: [ImportPathComponentSyntax(name: .identifier("LiveViewNative"))])
129+
130+
addonDecl
131+
}
132+
.formatted()
133+
.description
134+
.write(to: URL(string: outputFile)!, atomically: true, encoding: .utf8)
135+
}
136+
}
137+
138+
final class LiveElementVisitor: SyntaxVisitor {
139+
var liveElements = Set<String>()
140+
141+
override func visit(_ decl: StructDeclSyntax) -> SyntaxVisitorContinueKind {
142+
guard decl.attributes.contains(where: {
143+
guard case let .attribute(attribute) = $0
144+
else { return false }
145+
return attribute.attributeName.isLiveElementMacro
146+
})
147+
else { return .visitChildren }
148+
149+
liveElements.insert(decl.name.text)
150+
151+
return .visitChildren
152+
}
153+
}
154+
155+
extension TypeSyntax {
156+
var isLiveElementMacro: Bool {
157+
if let identifierType = self.as(IdentifierTypeSyntax.self) {
158+
return identifierType.name.text == "LiveElement"
159+
} else if let memberType = self.as(MemberTypeSyntax.self) {
160+
return memberType.baseType.as(IdentifierTypeSyntax.self)?.name.text == ""
161+
&& memberType.name.text == "LiveElement"
162+
} else {
163+
return false
164+
}
165+
}
166+
}

0 commit comments

Comments
 (0)