@expo/expo-modules-macros-plugin 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apple/ExpoModulesMacros-tool +0 -0
- package/apple/Package.swift +27 -2
- package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +148 -98
- package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +0 -52
- package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +60 -0
- package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +84 -0
- package/apple/Sources/ExpoModulesMacros/Receiver.swift +58 -0
- package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +5 -3
- package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +29 -90
- package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +61 -0
- package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +109 -0
- package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +137 -0
- package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +68 -0
- package/apple/Sources/ExpoModulesScannerCLI/main.swift +70 -0
- package/package.json +1 -1
|
@@ -245,3 +245,87 @@ extension AttributeListSyntax {
|
|
|
245
245
|
return nil
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
|
+
|
|
249
|
+
/// A type spelled so it's valid in expression position (before `.getDynamicType()` or after `as!`).
|
|
250
|
+
/// Implicitly-unwrapped optionals (`T!`) are only allowed in type-annotation position, so a trailing
|
|
251
|
+
/// `!` is rewritten to `?` (`T!` and `T?` are both `Optional<T>`, which the dynamic-type / cast layer
|
|
252
|
+
/// treats identically). Other type spellings pass through unchanged.
|
|
253
|
+
internal func expressionType(_ type: String) -> String {
|
|
254
|
+
guard type.hasSuffix("!") else {
|
|
255
|
+
return type
|
|
256
|
+
}
|
|
257
|
+
return type.dropLast() + "?"
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// MARK: - @JS property collection
|
|
261
|
+
|
|
262
|
+
/// Collects the `@JS var` bindings of a declaration into `JSProperty` values for direct JSI binding.
|
|
263
|
+
/// Shared between `@ExpoModule` and `@SharedObject` — the resulting properties are receiver-agnostic;
|
|
264
|
+
/// the decorator that emits them picks the receiver (module `self` vs. shared-object `_self`).
|
|
265
|
+
internal func collectProperties(
|
|
266
|
+
varDecl: VariableDeclSyntax,
|
|
267
|
+
attribute: AttributeSyntax
|
|
268
|
+
) -> [JSProperty] {
|
|
269
|
+
let jsNameOverride = jsNameArgument(of: attribute)
|
|
270
|
+
// A `let` is never settable; only `var` bindings can carry a setter.
|
|
271
|
+
let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
|
|
272
|
+
|
|
273
|
+
return varDecl.bindings.compactMap { binding in
|
|
274
|
+
guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
|
|
275
|
+
return nil
|
|
276
|
+
}
|
|
277
|
+
let swiftName = ident.identifier.text
|
|
278
|
+
// Prefer the explicit annotation; recover the type from a literal default (`var x = false`)
|
|
279
|
+
// when there's none. `nil` falls back to inference at the use site.
|
|
280
|
+
let valueType = binding.typeAnnotation?.type.trimmedDescription
|
|
281
|
+
?? binding.initializer.flatMap { inferredLiteralType(of: $0.value) }
|
|
282
|
+
return JSProperty(
|
|
283
|
+
swiftName: swiftName,
|
|
284
|
+
jsName: jsNameOverride ?? swiftName,
|
|
285
|
+
valueType: valueType,
|
|
286
|
+
isSettable: isVar && bindingIsSettable(binding)
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/// Whether a `var` binding is settable from JS. A stored property (no accessor block) is settable;
|
|
292
|
+
/// a computed property is settable only when it declares an explicit `set` accessor. A getter-only
|
|
293
|
+
/// computed property (`{ get }` or a single getter body) stays read-only. `willSet`/`didSet`
|
|
294
|
+
/// observers imply stored storage, which is also settable.
|
|
295
|
+
private func bindingIsSettable(_ binding: PatternBindingSyntax) -> Bool {
|
|
296
|
+
guard let accessorBlock = binding.accessorBlock else {
|
|
297
|
+
return true
|
|
298
|
+
}
|
|
299
|
+
switch accessorBlock.accessors {
|
|
300
|
+
case .accessors(let accessors):
|
|
301
|
+
return accessors.contains { accessor in
|
|
302
|
+
switch accessor.accessorSpecifier.tokenKind {
|
|
303
|
+
case .keyword(.set), .keyword(.willSet), .keyword(.didSet):
|
|
304
|
+
return true
|
|
305
|
+
default:
|
|
306
|
+
return false
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
case .getter:
|
|
310
|
+
return false
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/// The throwing `JavaScriptUnownedValue` accessor that decodes the given primitive type directly
|
|
315
|
+
/// (`asDouble()` for `Double`, etc.), bypassing the dynamic-type converter. Returns `nil` for types
|
|
316
|
+
/// without a dedicated accessor (arrays, records, optionals, shared objects, other numeric widths),
|
|
317
|
+
/// which decode through `getDynamicType().cast(...)`.
|
|
318
|
+
func fastDecodeAccessor(for type: String) -> String? {
|
|
319
|
+
switch type {
|
|
320
|
+
case "Bool":
|
|
321
|
+
return "asBool"
|
|
322
|
+
case "Int":
|
|
323
|
+
return "asInt"
|
|
324
|
+
case "Double":
|
|
325
|
+
return "asDouble"
|
|
326
|
+
case "String":
|
|
327
|
+
return "asString"
|
|
328
|
+
default:
|
|
329
|
+
return nil
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import SwiftSyntax
|
|
2
|
+
|
|
3
|
+
/// Where a directly-bound closure gets the Swift value it calls into. A module is a singleton, so its
|
|
4
|
+
/// bindings call `self` and ignore the JS `this`; a shared object has a distinct native instance per JS
|
|
5
|
+
/// object, so its bindings recover the typed receiver from `this`.
|
|
6
|
+
internal enum Receiver {
|
|
7
|
+
/// The module singleton; the closure captures `self` strong.
|
|
8
|
+
case module
|
|
9
|
+
/// A shared object of the given concrete type; the closure captures nothing and recovers the receiver
|
|
10
|
+
/// from `this` per call.
|
|
11
|
+
case sharedObject(typeName: String)
|
|
12
|
+
|
|
13
|
+
/// The expression the body calls members on: `self` for a module, `_self` (bound by `unwrapStatement`)
|
|
14
|
+
/// for a shared object. The leading underscore avoids colliding with a user member like `var owner`.
|
|
15
|
+
var callee: String {
|
|
16
|
+
switch self {
|
|
17
|
+
case .module:
|
|
18
|
+
return "self"
|
|
19
|
+
case .sharedObject:
|
|
20
|
+
return "_self"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// The JS object the decorator binds members onto, matching its first parameter: `object` for a
|
|
25
|
+
/// module (its own JS object), `prototype` for a shared object (the shared class prototype).
|
|
26
|
+
var decoratedObject: String {
|
|
27
|
+
switch self {
|
|
28
|
+
case .module:
|
|
29
|
+
return "object"
|
|
30
|
+
case .sharedObject:
|
|
31
|
+
return "prototype"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// The leading body line binding the receiver, or `nil` for a module (it reads `self` directly). For a
|
|
36
|
+
/// shared object, `native(from:as:)` recovers the typed instance from the borrowed `this`, throwing on
|
|
37
|
+
/// a foreign object or a type mismatch.
|
|
38
|
+
var unwrapStatement: String? {
|
|
39
|
+
switch self {
|
|
40
|
+
case .module:
|
|
41
|
+
return nil
|
|
42
|
+
case .sharedObject(let typeName):
|
|
43
|
+
return "let _self = try SharedObject.native(from: this.asObject(in: runtime), as: \(typeName).self)"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// The capture-clause fragment (with a trailing space, or empty when nothing is captured). A module
|
|
48
|
+
/// captures `self` strong; a shared object captures nothing of the instance. `appContext`, when used,
|
|
49
|
+
/// is captured weak in both cases.
|
|
50
|
+
func captureClause(usesAppContext: Bool) -> String {
|
|
51
|
+
switch self {
|
|
52
|
+
case .module:
|
|
53
|
+
return usesAppContext ? "[weak appContext, self] " : "[self] "
|
|
54
|
+
case .sharedObject:
|
|
55
|
+
return usesAppContext ? "[weak appContext] " : ""
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -316,7 +316,8 @@ private func jsObjectReadLines(properties: [RecordProperty]) -> [String] {
|
|
|
316
316
|
var lines: [String] = []
|
|
317
317
|
for property in properties {
|
|
318
318
|
let valueVar = "\(property.name)JSValue"
|
|
319
|
-
let
|
|
319
|
+
let exprType = expressionType(property.type)
|
|
320
|
+
let cast = "try \(exprType).getDynamicType().cast(jsValue: \(valueVar), appContext: appContext) as! \(exprType)"
|
|
320
321
|
lines.append(" let \(valueVar) = object.getProperty(\"\(property.name)\")")
|
|
321
322
|
if property.isRequired {
|
|
322
323
|
lines.append(" guard !\(valueVar).isUndefined() else {")
|
|
@@ -337,7 +338,8 @@ private func dictionaryReadLines(properties: [RecordProperty]) -> [String] {
|
|
|
337
338
|
var lines: [String] = []
|
|
338
339
|
for property in properties {
|
|
339
340
|
let valueVar = "\(property.name)Value"
|
|
340
|
-
let
|
|
341
|
+
let exprType = expressionType(property.type)
|
|
342
|
+
let cast = "try \(exprType).getDynamicType().cast(\(valueVar), appContext: appContext) as! \(exprType)"
|
|
341
343
|
lines.append(" let \(valueVar) = dictionary[\"\(property.name)\"]")
|
|
342
344
|
if property.isRequired {
|
|
343
345
|
lines.append(" guard let \(valueVar) else {")
|
|
@@ -399,7 +401,7 @@ private func toObjectMethod(properties: [RecordProperty], inheritsRecord: Bool)
|
|
|
399
401
|
lines.append(" let object = try appContext.runtime.createObject()")
|
|
400
402
|
}
|
|
401
403
|
for property in properties {
|
|
402
|
-
lines.append(" object.setProperty(\"\(property.name)\", value: try \(property.type).getDynamicType().convertToJS(self.\(property.name), appContext: appContext))")
|
|
404
|
+
lines.append(" object.setProperty(\"\(property.name)\", value: try \(expressionType(property.type)).getDynamicType().convertToJS(self.\(property.name), appContext: appContext))")
|
|
403
405
|
}
|
|
404
406
|
lines.append(" return object")
|
|
405
407
|
let body = lines.joined(separator: "\n")
|
|
@@ -41,35 +41,38 @@ public struct SharedObjectMacro: MemberMacro {
|
|
|
41
41
|
let typeName = classDecl.name.text
|
|
42
42
|
let jsName = jsNameArgument(of: node) ?? typeName
|
|
43
43
|
|
|
44
|
-
var
|
|
45
|
-
|
|
44
|
+
// `@JS func`s/`var`s and the `@JS init` are bound directly into the shared object's JS object by
|
|
45
|
+
// the synthesized `_decorateSharedObject` / `_constructSharedObject` rather than described with a
|
|
46
|
+
// `Function(...)` / `Property(...)` / `Constructor { … }` DSL entry, so they're collected here
|
|
47
|
+
// instead of appended to the `Class` block. The block keeps only non-`@JS` definitions (none are
|
|
48
|
+
// collected today), so it's empty when every member is `@JS`.
|
|
49
|
+
let entries: [String] = []
|
|
50
|
+
var functions: [JSFunction] = []
|
|
51
|
+
var properties: [JSProperty] = []
|
|
52
|
+
var constructor: JSConstructor?
|
|
46
53
|
|
|
47
54
|
for member in classDecl.memberBlock.members {
|
|
48
55
|
let decl = member.decl
|
|
49
56
|
|
|
50
57
|
if let initDecl = decl.as(InitializerDeclSyntax.self),
|
|
51
58
|
initDecl.attributes.firstAttribute(named: "JS") != nil {
|
|
52
|
-
if
|
|
59
|
+
if constructor != nil {
|
|
53
60
|
throw MacroExpansionErrorMessage(
|
|
54
61
|
"@SharedObject classes can have at most one @JS initializer; JavaScript classes have a single constructor.")
|
|
55
62
|
}
|
|
56
|
-
|
|
57
|
-
entries.append(buildConstructorEntry(initDecl: initDecl, typeName: typeName))
|
|
63
|
+
constructor = JSConstructor(initDecl: initDecl)
|
|
58
64
|
continue
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
if let funcDecl = decl.as(FunctionDeclSyntax.self),
|
|
62
68
|
let attribute = funcDecl.attributes.firstAttribute(named: "JS") {
|
|
63
|
-
|
|
64
|
-
buildClassFunctionEntry(funcDecl: funcDecl, attribute: attribute, typeName: typeName))
|
|
69
|
+
functions.append(JSFunction(funcDecl: funcDecl, attribute: attribute))
|
|
65
70
|
continue
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
if let varDecl = decl.as(VariableDeclSyntax.self),
|
|
69
74
|
let attribute = varDecl.attributes.firstAttribute(named: "JS") {
|
|
70
|
-
|
|
71
|
-
contentsOf: buildClassPropertyEntries(
|
|
72
|
-
varDecl: varDecl, attribute: attribute, typeName: typeName))
|
|
75
|
+
properties.append(contentsOf: collectProperties(varDecl: varDecl, attribute: attribute))
|
|
73
76
|
}
|
|
74
77
|
}
|
|
75
78
|
|
|
@@ -78,13 +81,27 @@ public struct SharedObjectMacro: MemberMacro {
|
|
|
78
81
|
? " return Class(\"\(jsName)\", \(typeName).self) {\n }"
|
|
79
82
|
: " return Class(\"\(jsName)\", \(typeName).self) {\n\(lines)\n }"
|
|
80
83
|
|
|
81
|
-
|
|
84
|
+
var emitted: [DeclSyntax] = [
|
|
85
|
+
"""
|
|
82
86
|
public static func _synthesizedClassDefinition() -> ClassDefinition {
|
|
83
87
|
\(raw: body)
|
|
84
88
|
}
|
|
85
89
|
"""
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
// Direct JSI binding: one `_decorateSharedObject` that binds each `@JS func`/`var` onto the JS
|
|
93
|
+
// object (unwrapping the per-call receiver from `this`), and a `_constructSharedObject` that
|
|
94
|
+
// builds an instance from the `@JS init` arguments. Each is emitted only when it has something
|
|
95
|
+
// to do.
|
|
96
|
+
if !functions.isEmpty || !properties.isEmpty {
|
|
97
|
+
emitted.append(
|
|
98
|
+
buildDecorateSharedObject(functions: functions, properties: properties, typeName: typeName))
|
|
99
|
+
}
|
|
100
|
+
if let constructor {
|
|
101
|
+
emitted.append(constructor.buildConstructor(typeName: typeName))
|
|
102
|
+
}
|
|
86
103
|
|
|
87
|
-
return
|
|
104
|
+
return emitted
|
|
88
105
|
}
|
|
89
106
|
}
|
|
90
107
|
|
|
@@ -111,81 +128,3 @@ extension SharedObjectMacro: MemberAttributeMacro {
|
|
|
111
128
|
private func inheritsFromSharedObject(_ classDecl: ClassDeclSyntax) -> Bool {
|
|
112
129
|
return inheritsFromAny(classDecl, names: ["SharedObject"])
|
|
113
130
|
}
|
|
114
|
-
|
|
115
|
-
// MARK: - Class-scope entry builders
|
|
116
|
-
|
|
117
|
-
private func buildClassFunctionEntry(
|
|
118
|
-
funcDecl: FunctionDeclSyntax,
|
|
119
|
-
attribute: AttributeSyntax,
|
|
120
|
-
typeName: String
|
|
121
|
-
) -> String {
|
|
122
|
-
let swiftName = funcDecl.name.text
|
|
123
|
-
let jsName = jsNameArgument(of: attribute) ?? swiftName
|
|
124
|
-
let effects = funcDecl.signature.effectSpecifiers
|
|
125
|
-
let isAsync = effects?.asyncSpecifier != nil
|
|
126
|
-
let isThrowing = effects?.throwsClause?.throwsSpecifier != nil
|
|
127
|
-
let dslEntry = isAsync ? "AsyncFunction" : "Function"
|
|
128
|
-
|
|
129
|
-
let params = funcDecl.signature.parameterClause.parameters
|
|
130
|
-
let closureParamList: String
|
|
131
|
-
let callArgList: String
|
|
132
|
-
if params.isEmpty {
|
|
133
|
-
closureParamList = "(this: \(typeName))"
|
|
134
|
-
callArgList = ""
|
|
135
|
-
} else {
|
|
136
|
-
let typedParams = params.enumerated().map { index, param in
|
|
137
|
-
"_ arg\(index): \(param.type.trimmedDescription)"
|
|
138
|
-
}.joined(separator: ", ")
|
|
139
|
-
closureParamList = "(this: \(typeName), \(typedParams))"
|
|
140
|
-
|
|
141
|
-
callArgList = params.enumerated().map { index, param in
|
|
142
|
-
let label = param.firstName.text
|
|
143
|
-
return label == "_" ? "arg\(index)" : "\(label): arg\(index)"
|
|
144
|
-
}.joined(separator: ", ")
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
let awaitKeyword = isAsync ? "await " : ""
|
|
148
|
-
let tryKeyword = (isAsync || isThrowing) ? "try " : ""
|
|
149
|
-
let callExpr = "\(tryKeyword)\(awaitKeyword)this.\(swiftName)(\(callArgList))"
|
|
150
|
-
|
|
151
|
-
return "\(dslEntry)(\"\(jsName)\") { \(closureParamList) in \(callExpr) }"
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
private func buildClassPropertyEntries(
|
|
155
|
-
varDecl: VariableDeclSyntax,
|
|
156
|
-
attribute: AttributeSyntax,
|
|
157
|
-
typeName: String
|
|
158
|
-
) -> [String] {
|
|
159
|
-
let jsNameOverride = jsNameArgument(of: attribute)
|
|
160
|
-
|
|
161
|
-
return varDecl.bindings.compactMap { binding in
|
|
162
|
-
guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
|
|
163
|
-
return nil
|
|
164
|
-
}
|
|
165
|
-
let swiftName = ident.identifier.text
|
|
166
|
-
let jsName = jsNameOverride ?? swiftName
|
|
167
|
-
return "Property(\"\(jsName)\") { (this: \(typeName)) in this.\(swiftName) }"
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
private func buildConstructorEntry(
|
|
172
|
-
initDecl: InitializerDeclSyntax,
|
|
173
|
-
typeName: String
|
|
174
|
-
) -> String {
|
|
175
|
-
let params = initDecl.signature.parameterClause.parameters
|
|
176
|
-
|
|
177
|
-
if params.isEmpty {
|
|
178
|
-
return "Constructor { \(typeName)() }"
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
let argList = params.enumerated().map { index, param in
|
|
182
|
-
"_ arg\(index): \(param.type.trimmedDescription)"
|
|
183
|
-
}.joined(separator: ", ")
|
|
184
|
-
|
|
185
|
-
let callArgs = params.enumerated().map { index, param in
|
|
186
|
-
let label = param.firstName.text
|
|
187
|
-
return label == "_" ? "arg\(index)" : "\(label): arg\(index)"
|
|
188
|
-
}.joined(separator: ", ")
|
|
189
|
-
|
|
190
|
-
return "Constructor { (\(argList)) in \(typeName)(\(callArgs)) }"
|
|
191
|
-
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Which Expo macro was found on a declaration. The scanner recognizes the entry-point macros that
|
|
4
|
+
/// mark a type or member as part of a module's JS surface, plus `@Record` for convertible types.
|
|
5
|
+
enum DetectedMacro: String, Codable, CaseIterable {
|
|
6
|
+
case expoModule = "ExpoModule"
|
|
7
|
+
case js = "JS"
|
|
8
|
+
case sharedObject = "SharedObject"
|
|
9
|
+
case record = "Record"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/// A single argument passed to a macro, e.g. `"Foo"` or `classes: [Bar.self]`. The label is `nil`
|
|
13
|
+
/// for positional arguments; `value` is the argument expression's source text as written.
|
|
14
|
+
struct MacroArgument: Codable, Equatable {
|
|
15
|
+
/// The argument label (`classes` in `classes: [Bar.self]`), or `nil` for a positional argument.
|
|
16
|
+
let label: String?
|
|
17
|
+
|
|
18
|
+
/// The argument value exactly as written in source, e.g. `"Foo"` (including the quotes) or
|
|
19
|
+
/// `[Bar.self]`. Kept as text because a syntactic scan can't resolve these to runtime values.
|
|
20
|
+
let value: String
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// A single annotated declaration the scanner found, with just enough to locate it and know
|
|
24
|
+
/// what it is. Member-level details (parameters, types) are intentionally out of scope for this
|
|
25
|
+
/// first prototype — see the `@JS` member walk in the macros for where that would live.
|
|
26
|
+
struct Detection: Codable, Equatable {
|
|
27
|
+
/// The macro spelled on the declaration (without the leading `@`).
|
|
28
|
+
let macro: DetectedMacro
|
|
29
|
+
|
|
30
|
+
/// The declared name, e.g. the class name for `@ExpoModule`, or the func/var/init name for `@JS`.
|
|
31
|
+
let name: String
|
|
32
|
+
|
|
33
|
+
/// The kind of declaration the macro was attached to: `class`, `struct`, `func`, `var`, `init`, …
|
|
34
|
+
let declarationKind: String
|
|
35
|
+
|
|
36
|
+
/// The explicit JS name override when written as `@ExpoModule("Foo")` / `@JS("bar")` /
|
|
37
|
+
/// `@SharedObject("Baz")`, otherwise `nil` (the name defaults to `name` at expansion time).
|
|
38
|
+
let jsName: String?
|
|
39
|
+
|
|
40
|
+
/// Every argument passed to the macro, in source order, e.g. `@ExpoModule("Foo", classes: [Bar.self])`
|
|
41
|
+
/// yields a positional `"Foo"` and a `classes:` argument. Empty when the macro is written bare.
|
|
42
|
+
let arguments: [MacroArgument]
|
|
43
|
+
|
|
44
|
+
/// Source location, relative to the path the scanner was invoked with.
|
|
45
|
+
let file: String
|
|
46
|
+
let line: Int
|
|
47
|
+
let column: Int
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Counts describing how much work the scan did, so callers can see the pre-filter's effect: of all
|
|
51
|
+
/// the `.swift` files read, how many actually needed parsing, and how long the run took.
|
|
52
|
+
struct ScanStats: Codable, Equatable {
|
|
53
|
+
/// `.swift` files the walk found and read (after directory pruning).
|
|
54
|
+
let filesScanned: Int
|
|
55
|
+
|
|
56
|
+
/// Of those, how many contained a macro attribute and so were parsed with SwiftSyntax.
|
|
57
|
+
let filesParsed: Int
|
|
58
|
+
|
|
59
|
+
/// Wall-clock duration of the scan, in milliseconds (walking, reading, filtering, and parsing).
|
|
60
|
+
let durationMs: Double
|
|
61
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import SwiftSyntax
|
|
2
|
+
|
|
3
|
+
/// Walks a parsed source file and records top-level declarations carrying `@ExpoModule`, `@JS`,
|
|
4
|
+
/// `@SharedObject`, or `@Record`. Only file-scope declarations are considered: these macros apply to
|
|
5
|
+
/// top-level types, so descending into type and function bodies would only surface false positives.
|
|
6
|
+
/// Recognition mirrors the macros themselves — a purely syntactic match on the spelled attribute
|
|
7
|
+
/// name — so it sees the same declarations the compiler would hand the plugin, without compiling
|
|
8
|
+
/// anything.
|
|
9
|
+
final class DetectionVisitor: SyntaxVisitor {
|
|
10
|
+
private let file: String
|
|
11
|
+
private let converter: SourceLocationConverter
|
|
12
|
+
/// Only these macros are recorded; the rest are ignored. Lets a `modules` scan report just
|
|
13
|
+
/// `@ExpoModule` while an `exports` scan covers them all.
|
|
14
|
+
private let detectedMacros: Set<DetectedMacro>
|
|
15
|
+
private(set) var detections: [Detection] = []
|
|
16
|
+
|
|
17
|
+
init(file: String, tree: SourceFileSyntax, detectedMacros: Set<DetectedMacro>) {
|
|
18
|
+
self.file = file
|
|
19
|
+
self.converter = SourceLocationConverter(fileName: file, tree: tree)
|
|
20
|
+
self.detectedMacros = detectedMacros
|
|
21
|
+
super.init(viewMode: .sourceAccurate)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
25
|
+
if isTopLevel(node) {
|
|
26
|
+
record(attributes: node.attributes, name: node.name.text, kind: "class", at: node)
|
|
27
|
+
}
|
|
28
|
+
// Members live in the type body; we never report them, so there's no reason to descend.
|
|
29
|
+
return .skipChildren
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
33
|
+
if isTopLevel(node) {
|
|
34
|
+
record(attributes: node.attributes, name: node.name.text, kind: "struct", at: node)
|
|
35
|
+
}
|
|
36
|
+
return .skipChildren
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// True when the declaration sits at file scope: its parent is a `CodeBlockItemSyntax` directly
|
|
40
|
+
/// under the source file's top-level item list. Members of a type are nested in a
|
|
41
|
+
/// `MemberBlockItemSyntax` instead, so they don't match.
|
|
42
|
+
///
|
|
43
|
+
/// TODO: decide whether to support nested types. A macro on a type nested in another type/enum/
|
|
44
|
+
/// extension is valid Swift but missed here; supporting it means descending into type bodies and
|
|
45
|
+
/// recording the enclosing path for a qualified name (e.g. `Namespace.InnerModule`).
|
|
46
|
+
private func isTopLevel(_ node: some SyntaxProtocol) -> Bool {
|
|
47
|
+
guard let item = node.parent?.as(CodeBlockItemSyntax.self) else {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
return item.parent?.parent?.is(SourceFileSyntax.self) == true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// Emits one detection per recognized Expo attribute on the declaration. A declaration can in
|
|
54
|
+
/// principle carry more than one (uncommon), so each is recorded independently.
|
|
55
|
+
private func record(
|
|
56
|
+
attributes: AttributeListSyntax,
|
|
57
|
+
name: String,
|
|
58
|
+
kind: String,
|
|
59
|
+
at node: some SyntaxProtocol
|
|
60
|
+
) {
|
|
61
|
+
for element in attributes {
|
|
62
|
+
guard let attribute = element.as(AttributeSyntax.self),
|
|
63
|
+
let macro = DetectedMacro(rawValue: attribute.attributeName.trimmedDescription),
|
|
64
|
+
detectedMacros.contains(macro) else {
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
let location = converter.location(for: node.positionAfterSkippingLeadingTrivia)
|
|
68
|
+
detections.append(
|
|
69
|
+
Detection(
|
|
70
|
+
macro: macro,
|
|
71
|
+
name: name,
|
|
72
|
+
declarationKind: kind,
|
|
73
|
+
jsName: stringArgument(of: attribute),
|
|
74
|
+
arguments: arguments(of: attribute),
|
|
75
|
+
file: file,
|
|
76
|
+
line: location.line,
|
|
77
|
+
column: location.column
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Every argument passed to the attribute, in source order, each as a label (or `nil` when
|
|
85
|
+
/// positional) plus the value expression's source text. Returns an empty array when the attribute
|
|
86
|
+
/// is written bare (`@ExpoModule`) or with empty parens.
|
|
87
|
+
private func arguments(of attribute: AttributeSyntax) -> [MacroArgument] {
|
|
88
|
+
guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
|
|
89
|
+
return []
|
|
90
|
+
}
|
|
91
|
+
return args.map { arg in
|
|
92
|
+
MacroArgument(label: arg.label?.text, value: arg.expression.trimmedDescription)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// The first string-literal argument of an attribute, e.g. `@JS("doWork")` -> "doWork". Returns
|
|
97
|
+
/// `nil` when there's no argument or it isn't a plain string literal. (Same shape the
|
|
98
|
+
/// `jsNameArgument` helper reads inside the macros.)
|
|
99
|
+
private func stringArgument(of attribute: AttributeSyntax) -> String? {
|
|
100
|
+
guard let args = attribute.arguments?.as(LabeledExprListSyntax.self),
|
|
101
|
+
let first = args.first,
|
|
102
|
+
first.label == nil,
|
|
103
|
+
let str = first.expression.as(StringLiteralExprSyntax.self),
|
|
104
|
+
let segment = str.segments.first?.as(StringSegmentSyntax.self),
|
|
105
|
+
str.segments.count == 1 else {
|
|
106
|
+
return nil
|
|
107
|
+
}
|
|
108
|
+
return segment.content.text
|
|
109
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import SwiftParser
|
|
3
|
+
import SwiftSyntax
|
|
4
|
+
|
|
5
|
+
/// Walks `paths`, parses each `.swift` file that might contain one of `macros` (the pre-filter), and
|
|
6
|
+
/// returns every detection (in file then source order) with the run's stats. The shared core every
|
|
7
|
+
/// scan command builds on; each command projects these detections into its own output shape.
|
|
8
|
+
func collectDetections(paths: [String], macros: Set<DetectedMacro>) -> (detections: [Detection], stats: ScanStats) {
|
|
9
|
+
let clock = ContinuousClock()
|
|
10
|
+
let start = clock.now
|
|
11
|
+
|
|
12
|
+
var detections: [Detection] = []
|
|
13
|
+
var filesScanned = 0
|
|
14
|
+
var filesParsed = 0
|
|
15
|
+
|
|
16
|
+
// Compile the pre-filter regex once per run, not once per file.
|
|
17
|
+
let prefilter = macroAttributeRegex(for: macros)
|
|
18
|
+
|
|
19
|
+
for file in swiftFiles(in: paths) {
|
|
20
|
+
guard let source = try? String(contentsOfFile: file, encoding: .utf8) else {
|
|
21
|
+
FileHandle.standardError.write(Data("warning: could not read \(file)\n".utf8))
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
filesScanned += 1
|
|
25
|
+
// Skip the (relatively expensive) parse for files that can't contain any of the macros. A plain
|
|
26
|
+
// substring scan is far cheaper than a full parse, and most files in a large tree mention none
|
|
27
|
+
// of these names. See `mightContainMacro` for why this never drops a real match.
|
|
28
|
+
guard mightContainMacro(in: source, prefilter: prefilter) else {
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
filesParsed += 1
|
|
32
|
+
detections.append(contentsOf: detect(source: source, file: file, macros: macros))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let elapsed = (clock.now - start).components
|
|
36
|
+
let durationMs = Double(elapsed.seconds) * 1000 + Double(elapsed.attoseconds) / 1e15
|
|
37
|
+
|
|
38
|
+
return (detections, ScanStats(filesScanned: filesScanned, filesParsed: filesParsed, durationMs: durationMs))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Parses one source string and returns its detections for the given macro set. The unit of work the
|
|
42
|
+
/// tests exercise.
|
|
43
|
+
func detect(source: String, file: String, macros: Set<DetectedMacro>) -> [Detection] {
|
|
44
|
+
let tree = Parser.parse(source: source)
|
|
45
|
+
let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: macros)
|
|
46
|
+
visitor.walk(tree)
|
|
47
|
+
return visitor.detections
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// MARK: - Pre-filter
|
|
51
|
+
|
|
52
|
+
/// Builds the pre-filter regex for a macro set, e.g. `@(ExpoModule)` for a `modules` scan or
|
|
53
|
+
/// `@(ExpoModule|JS|Record|SharedObject)` for an `exports` scan. A precompiled `NSRegularExpression`
|
|
54
|
+
/// benchmarked ~20x faster over a large source tree than calling `String.contains` once per macro
|
|
55
|
+
/// name, because it scans each file in a single pass. Compiled once per run and reused per file.
|
|
56
|
+
func macroAttributeRegex(for macros: Set<DetectedMacro>) -> NSRegularExpression {
|
|
57
|
+
// Sort for a stable pattern regardless of the set's iteration order.
|
|
58
|
+
let alternation = macros.map(\.rawValue).sorted().joined(separator: "|")
|
|
59
|
+
return try! NSRegularExpression(pattern: "@(\(alternation))")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// True if the source text contains one of the pre-filter's spelled macro attributes, so it's worth
|
|
63
|
+
/// parsing. A deliberate over-approximation: the pattern can still match inside a comment or string,
|
|
64
|
+
/// in which case the file is parsed and correctly yields no detections — a wasted parse, never a
|
|
65
|
+
/// missed module. It assumes the attribute is written with no space after `@` (`@ExpoModule`, not
|
|
66
|
+
/// `@ ExpoModule`), which is universal in practice; the rare spaced form would be skipped.
|
|
67
|
+
func mightContainMacro(in source: String, prefilter: NSRegularExpression) -> Bool {
|
|
68
|
+
let range = NSRange(source.startIndex..., in: source)
|
|
69
|
+
return prefilter.firstMatch(in: source, range: range) != nil
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// MARK: - File discovery
|
|
73
|
+
|
|
74
|
+
/// Directory names skipped during the recursive walk. These hold build products, dependencies, and
|
|
75
|
+
/// git internals — never source worth scanning — and pruning them keeps the walk from descending
|
|
76
|
+
/// into the bulk of a monorepo's files.
|
|
77
|
+
private let prunedDirectoryNames: Set<String> = [".build", "Pods", ".git"]
|
|
78
|
+
|
|
79
|
+
/// Expands the given paths into the list of `.swift` files to parse: a file path passes through,
|
|
80
|
+
/// a directory is enumerated recursively (skipping `prunedDirectoryNames`). Order is deterministic
|
|
81
|
+
/// so output is stable across runs.
|
|
82
|
+
///
|
|
83
|
+
/// Reported paths are absolute, so the output is unambiguous and independent of the caller's working
|
|
84
|
+
/// directory. (A future `--root` option could emit paths relative to a given base when a portable,
|
|
85
|
+
/// shorter form is wanted.)
|
|
86
|
+
func swiftFiles(in paths: [String]) -> [String] {
|
|
87
|
+
let fileManager = FileManager.default
|
|
88
|
+
var result: [String] = []
|
|
89
|
+
|
|
90
|
+
for path in paths {
|
|
91
|
+
var isDirectory: ObjCBool = false
|
|
92
|
+
guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else {
|
|
93
|
+
FileHandle.standardError.write(Data("warning: no such path \(path)\n".utf8))
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if isDirectory.boolValue {
|
|
98
|
+
result.append(contentsOf: swiftFiles(inDirectory: URL(fileURLWithPath: path), fileManager: fileManager))
|
|
99
|
+
} else if path.hasSuffix(".swift") {
|
|
100
|
+
// A directory walk already yields absolute paths; resolve a directly-passed file the same way
|
|
101
|
+
// so every reported path is absolute regardless of how it was spelled.
|
|
102
|
+
result.append(URL(fileURLWithPath: path).standardizedFileURL.path)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return result.sorted()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Recursively enumerates `.swift` files under a directory, calling `skipDescendants()` on any
|
|
110
|
+
/// pruned directory so its subtree is never read. Uses the URL enumerator (rather than the
|
|
111
|
+
/// path-based one) precisely because it supports skipping a subtree mid-walk.
|
|
112
|
+
///
|
|
113
|
+
/// Directory-ness is read from `hasDirectoryPath` (the enumerator sets a trailing slash on the URLs
|
|
114
|
+
/// it yields) rather than `resourceValues(forKeys: [.isDirectoryKey])`, which re-`stat`s each entry.
|
|
115
|
+
/// The walk is the dominant cost of a whole-tree scan, and skipping that per-entry stat measurably
|
|
116
|
+
/// shortens it.
|
|
117
|
+
private func swiftFiles(inDirectory directory: URL, fileManager: FileManager) -> [String] {
|
|
118
|
+
guard let enumerator = fileManager.enumerator(
|
|
119
|
+
at: directory,
|
|
120
|
+
includingPropertiesForKeys: nil,
|
|
121
|
+
options: [.skipsHiddenFiles]
|
|
122
|
+
) else {
|
|
123
|
+
return []
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
var result: [String] = []
|
|
127
|
+
for case let url as URL in enumerator {
|
|
128
|
+
if url.hasDirectoryPath {
|
|
129
|
+
if prunedDirectoryNames.contains(url.lastPathComponent) {
|
|
130
|
+
enumerator.skipDescendants()
|
|
131
|
+
}
|
|
132
|
+
} else if url.pathExtension == "swift" {
|
|
133
|
+
result.append(url.path)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return result
|
|
137
|
+
}
|