expo-modules-macros 0.0.1 → 0.15.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/.github/resources/expo-modules-macros.svg +23 -0
- package/.github/workflows/publish.yml +122 -0
- package/.github/workflows/swift.yml +68 -0
- package/AGENTS.md +59 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/apple/ExpoModulesMacros +0 -0
- package/apple/Package.resolved +15 -0
- package/apple/Package.swift +61 -0
- package/apple/PodTests/StubTest.swift +5 -0
- package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +444 -0
- package/apple/Sources/ExpoModulesMacros/EventMacro.swift +330 -0
- package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +263 -0
- package/apple/Sources/ExpoModulesMacros/ExpoModulesOptimizedMacro.swift +117 -0
- package/apple/Sources/ExpoModulesMacros/ExpoViewMacro.swift +259 -0
- package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +214 -0
- package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +55 -0
- package/apple/Sources/ExpoModulesMacros/JSMacro.swift +294 -0
- package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +433 -0
- package/apple/Sources/ExpoModulesMacros/OptimizedFunctionHelpers.swift +55 -0
- package/apple/Sources/ExpoModulesMacros/Plugin.swift +34 -0
- package/apple/Sources/ExpoModulesMacros/Receiver.swift +79 -0
- package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +590 -0
- package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +162 -0
- package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +190 -0
- package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +389 -0
- package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +477 -0
- package/apple/Sources/ExpoModulesOptimized/ExpoModulesOptimized.swift +19 -0
- package/apple/Sources/ExpoModulesScanner/CLI.swift +114 -0
- package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +79 -0
- package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +181 -0
- package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +115 -0
- package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +193 -0
- package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +319 -0
- package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +199 -0
- package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +66 -0
- package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +616 -0
- package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +284 -0
- package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +161 -0
- package/apple/build.js +138 -0
- package/build/index.d.ts +51 -0
- package/build/index.js +153 -0
- package/build/types.d.ts +264 -0
- package/build/types.js +15 -0
- package/package.json +22 -5
|
@@ -0,0 +1,79 @@
|
|
|
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 event = "Event"
|
|
9
|
+
case sharedObject = "SharedObject"
|
|
10
|
+
case record = "Record"
|
|
11
|
+
case union = "Union"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/// A single argument passed to a macro, e.g. `"Foo"` or `classes: [Bar.self]`. The label is `nil`
|
|
15
|
+
/// for positional arguments; `value` is the argument expression's source text as written.
|
|
16
|
+
struct MacroArgument: Codable, Equatable {
|
|
17
|
+
/// The argument label (`classes` in `classes: [Bar.self]`), or `nil` for a positional argument.
|
|
18
|
+
let label: String?
|
|
19
|
+
|
|
20
|
+
/// The argument value exactly as written in source, e.g. `"Foo"` (including the quotes) or
|
|
21
|
+
/// `[Bar.self]`. Kept as text because a syntactic scan can't resolve these to runtime values.
|
|
22
|
+
let value: String
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// A single annotated declaration the scanner found, with just enough to locate it and know
|
|
26
|
+
/// what it is. Member-level details (parameters, types) are intentionally out of scope for this
|
|
27
|
+
/// first prototype — see the `@JS` member walk in the macros for where that would live.
|
|
28
|
+
struct Detection: Codable, Equatable {
|
|
29
|
+
/// The macro spelled on the declaration (without the leading `@`).
|
|
30
|
+
let macro: DetectedMacro
|
|
31
|
+
|
|
32
|
+
/// The declared name, e.g. the class name for `@ExpoModule`, or the func/var/init name for `@JS`.
|
|
33
|
+
let name: String
|
|
34
|
+
|
|
35
|
+
/// The kind of declaration the macro was attached to: `class`, `struct`, `func`, `var`, `init`, …
|
|
36
|
+
let declarationKind: String
|
|
37
|
+
|
|
38
|
+
/// The declaration's spelled access modifier (`open`, `public`, `package`, `fileprivate`,
|
|
39
|
+
/// `private`), or `internal` when none is written. Consumers that reference the declaration from
|
|
40
|
+
/// another Swift module (the generated modules provider does) need `public`/`open` and can reject
|
|
41
|
+
/// the rest up front instead of failing at compile time.
|
|
42
|
+
let accessLevel: String
|
|
43
|
+
|
|
44
|
+
/// The explicit JS name override when written as `@ExpoModule("Foo")` / `@JS("bar")` /
|
|
45
|
+
/// `@SharedObject("Baz")`, otherwise `nil` (the name defaults to `name` at expansion time).
|
|
46
|
+
let jsName: String?
|
|
47
|
+
|
|
48
|
+
/// Every argument passed to the macro, in source order, e.g. `@ExpoModule("Foo", classes: [Bar.self])`
|
|
49
|
+
/// yields a positional `"Foo"` and a `classes:` argument. Empty when the macro is written bare.
|
|
50
|
+
let arguments: [MacroArgument]
|
|
51
|
+
|
|
52
|
+
/// Source location, relative to the path the scanner was invoked with.
|
|
53
|
+
let file: String
|
|
54
|
+
let line: Int
|
|
55
|
+
let column: Int
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// A non-fatal problem found while scanning, tied to the source location that caused it — today,
|
|
59
|
+
/// an `#if` condition a static scan cannot answer (`canImport` of a non-SDK module, `arch`,
|
|
60
|
+
/// `targetEnvironment`, …). The affected region counts as inactive on every platform, so the
|
|
61
|
+
/// warning tells the consumer which declarations may be missing platforms and why.
|
|
62
|
+
struct ScanWarning: Codable, Equatable, Hashable {
|
|
63
|
+
let message: String
|
|
64
|
+
let file: String
|
|
65
|
+
let line: Int
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Counts describing how much work the scan did, so callers can see the pre-filter's effect: of all
|
|
69
|
+
/// the `.swift` files read, how many actually needed parsing, and how long the run took.
|
|
70
|
+
struct ScanStats: Codable, Equatable {
|
|
71
|
+
/// `.swift` files the walk found and read (after directory pruning).
|
|
72
|
+
let filesScanned: Int
|
|
73
|
+
|
|
74
|
+
/// Of those, how many contained a macro attribute and so were parsed with SwiftSyntax.
|
|
75
|
+
let filesParsed: Int
|
|
76
|
+
|
|
77
|
+
/// Wall-clock duration of the scan, in milliseconds (walking, reading, filtering, and parsing).
|
|
78
|
+
let durationMs: Double
|
|
79
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import SwiftDiagnostics
|
|
2
|
+
import SwiftIfConfig
|
|
3
|
+
import SwiftSyntax
|
|
4
|
+
|
|
5
|
+
/// Walks a parsed source file and records top-level declarations carrying `@ExpoModule`, `@JS`,
|
|
6
|
+
/// `@SharedObject`, or `@Record`. Only file-scope declarations are considered: these macros apply to
|
|
7
|
+
/// top-level types, so descending into type and function bodies would only surface false positives.
|
|
8
|
+
/// Recognition mirrors the macros themselves — a purely syntactic match on the spelled attribute
|
|
9
|
+
/// name — so it sees the same declarations the compiler would hand the plugin, without compiling
|
|
10
|
+
/// anything.
|
|
11
|
+
///
|
|
12
|
+
/// `#if` blocks are handled according to the scan's configuration. With a `ScanBuildConfiguration`,
|
|
13
|
+
/// only the active clause of each `#if` is visited, so a declaration inside `#if os(tvOS)` is
|
|
14
|
+
/// recorded exactly when the scan targets tvOS, and conditions the configuration cannot answer make
|
|
15
|
+
/// their region inactive and surface as warnings. With a `nil` configuration every clause of every
|
|
16
|
+
/// `#if` is visited, so a declaration in any branch is recorded and nothing is evaluated; the
|
|
17
|
+
/// per-platform aggregation in `scanModules` builds on both walks.
|
|
18
|
+
final class DetectionVisitor: SyntaxVisitor {
|
|
19
|
+
private let file: String
|
|
20
|
+
private let converter: SourceLocationConverter
|
|
21
|
+
/// Only these macros are recorded; the rest are ignored. Lets a `modules` scan report just
|
|
22
|
+
/// `@ExpoModule` while an `exports` scan covers them all.
|
|
23
|
+
private let detectedMacros: Set<DetectedMacro>
|
|
24
|
+
/// The configuration `#if` conditions are evaluated against, or `nil` to visit every branch.
|
|
25
|
+
private let configuration: ScanBuildConfiguration?
|
|
26
|
+
private var diagnostics: [Diagnostic] = []
|
|
27
|
+
private(set) var detections: [Detection] = []
|
|
28
|
+
|
|
29
|
+
init(
|
|
30
|
+
file: String,
|
|
31
|
+
tree: SourceFileSyntax,
|
|
32
|
+
detectedMacros: Set<DetectedMacro>,
|
|
33
|
+
configuration: ScanBuildConfiguration?
|
|
34
|
+
) {
|
|
35
|
+
self.file = file
|
|
36
|
+
self.converter = SourceLocationConverter(fileName: file, tree: tree)
|
|
37
|
+
self.detectedMacros = detectedMacros
|
|
38
|
+
self.configuration = configuration
|
|
39
|
+
super.init(viewMode: .sourceAccurate)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// The accumulated `#if` warnings as `Detection`-style locations plus the message, ready for the
|
|
43
|
+
/// scan report. These come from conditions the configuration cannot answer statically.
|
|
44
|
+
var warnings: [ScanWarning] {
|
|
45
|
+
return diagnostics.map { diagnostic in
|
|
46
|
+
let location = diagnostic.location(converter: converter)
|
|
47
|
+
return ScanWarning(message: diagnostic.message, file: file, line: location.line)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
52
|
+
guard let configuration else {
|
|
53
|
+
// Every branch is visited, so a declaration in any clause is recorded.
|
|
54
|
+
return .visitChildren
|
|
55
|
+
}
|
|
56
|
+
let (clause, clauseDiagnostics) = node.activeClause(in: configuration)
|
|
57
|
+
diagnostics.append(contentsOf: clauseDiagnostics)
|
|
58
|
+
if let elements = clause?.elements {
|
|
59
|
+
// A nested `#if` inside the active clause comes back through this method.
|
|
60
|
+
walk(elements)
|
|
61
|
+
}
|
|
62
|
+
return .skipChildren
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
66
|
+
if isTopLevel(node) {
|
|
67
|
+
record(attributes: node.attributes, modifiers: node.modifiers, name: node.name.text, kind: "class", at: node)
|
|
68
|
+
}
|
|
69
|
+
// Members live in the type body; we never report them, so there's no reason to descend.
|
|
70
|
+
return .skipChildren
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
74
|
+
if isTopLevel(node) {
|
|
75
|
+
record(attributes: node.attributes, modifiers: node.modifiers, name: node.name.text, kind: "struct", at: node)
|
|
76
|
+
}
|
|
77
|
+
return .skipChildren
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// True when the declaration sits at file scope: its parent chain reaches the source file through
|
|
81
|
+
/// only code-block items and `#if` structure. Members of a type are nested in a
|
|
82
|
+
/// `MemberBlockItemSyntax` instead, so they don't match. The `#if` wrappers are allowed because a
|
|
83
|
+
/// top-level declaration inside an (active) `#if` clause is still a top-level declaration.
|
|
84
|
+
///
|
|
85
|
+
/// TODO: decide whether to support nested types. A macro on a type nested in another type/enum/
|
|
86
|
+
/// extension is valid Swift but missed here; supporting it means descending into type bodies and
|
|
87
|
+
/// recording the enclosing path for a qualified name (e.g. `Namespace.InnerModule`).
|
|
88
|
+
private func isTopLevel(_ node: some SyntaxProtocol) -> Bool {
|
|
89
|
+
guard node.parent?.is(CodeBlockItemSyntax.self) == true else {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
var current = node.parent?.parent
|
|
93
|
+
while let node = current {
|
|
94
|
+
if node.is(SourceFileSyntax.self) {
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
guard node.is(CodeBlockItemListSyntax.self)
|
|
98
|
+
|| node.is(CodeBlockItemSyntax.self)
|
|
99
|
+
|| node.is(IfConfigClauseSyntax.self)
|
|
100
|
+
|| node.is(IfConfigClauseListSyntax.self)
|
|
101
|
+
|| node.is(IfConfigDeclSyntax.self) else {
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
current = node.parent
|
|
105
|
+
}
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Emits one detection per recognized Expo attribute on the declaration. A declaration can in
|
|
110
|
+
/// principle carry more than one (uncommon), so each is recorded independently.
|
|
111
|
+
private func record(
|
|
112
|
+
attributes: AttributeListSyntax,
|
|
113
|
+
modifiers: DeclModifierListSyntax,
|
|
114
|
+
name: String,
|
|
115
|
+
kind: String,
|
|
116
|
+
at node: some SyntaxProtocol
|
|
117
|
+
) {
|
|
118
|
+
for element in attributes {
|
|
119
|
+
guard let attribute = element.as(AttributeSyntax.self),
|
|
120
|
+
let macro = DetectedMacro(rawValue: attribute.attributeName.trimmedDescription),
|
|
121
|
+
detectedMacros.contains(macro) else {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
let location = converter.location(for: node.positionAfterSkippingLeadingTrivia)
|
|
125
|
+
detections.append(
|
|
126
|
+
Detection(
|
|
127
|
+
macro: macro,
|
|
128
|
+
name: name,
|
|
129
|
+
declarationKind: kind,
|
|
130
|
+
accessLevel: accessLevel(of: modifiers),
|
|
131
|
+
jsName: stringArgument(of: attribute),
|
|
132
|
+
arguments: arguments(of: attribute),
|
|
133
|
+
file: file,
|
|
134
|
+
line: location.line,
|
|
135
|
+
column: location.column
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// The spelled access modifiers, in the order Swift defines them. Other modifiers (`final`,
|
|
143
|
+
/// `static`, …) are not access levels and are skipped when resolving one.
|
|
144
|
+
private let accessModifierNames: Set<String> = ["open", "public", "package", "internal", "fileprivate", "private"]
|
|
145
|
+
|
|
146
|
+
/// The declaration's access level: the first spelled access modifier, or Swift's default of
|
|
147
|
+
/// `internal` when none is written. A declaration can spell at most one, so first is the only one.
|
|
148
|
+
private func accessLevel(of modifiers: DeclModifierListSyntax) -> String {
|
|
149
|
+
for modifier in modifiers where accessModifierNames.contains(modifier.name.text) {
|
|
150
|
+
return modifier.name.text
|
|
151
|
+
}
|
|
152
|
+
return "internal"
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/// Every argument passed to the attribute, in source order, each as a label (or `nil` when
|
|
156
|
+
/// positional) plus the value expression's source text. Returns an empty array when the attribute
|
|
157
|
+
/// is written bare (`@ExpoModule`) or with empty parens.
|
|
158
|
+
private func arguments(of attribute: AttributeSyntax) -> [MacroArgument] {
|
|
159
|
+
guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
|
|
160
|
+
return []
|
|
161
|
+
}
|
|
162
|
+
return args.map { arg in
|
|
163
|
+
MacroArgument(label: arg.label?.text, value: arg.expression.trimmedDescription)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// The first string-literal argument of an attribute, e.g. `@JS("doWork")` -> "doWork". Returns
|
|
168
|
+
/// `nil` when there's no argument or it isn't a plain string literal. (Same shape the
|
|
169
|
+
/// `jsNameArgument` helper reads inside the macros.) Shared with the `scan-exports` surface visitor,
|
|
170
|
+
/// which reads the same JS-name override off `@JS`/`@ExpoModule`/`@SharedObject`.
|
|
171
|
+
func stringArgument(of attribute: AttributeSyntax) -> String? {
|
|
172
|
+
guard let args = attribute.arguments?.as(LabeledExprListSyntax.self),
|
|
173
|
+
let first = args.first,
|
|
174
|
+
first.label == nil,
|
|
175
|
+
let str = first.expression.as(StringLiteralExprSyntax.self),
|
|
176
|
+
let segment = str.segments.first?.as(StringSegmentSyntax.self),
|
|
177
|
+
str.segments.count == 1 else {
|
|
178
|
+
return nil
|
|
179
|
+
}
|
|
180
|
+
return segment.content.text
|
|
181
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import SwiftIfConfig
|
|
2
|
+
import SwiftSyntax
|
|
3
|
+
|
|
4
|
+
/// The build configuration that `#if` conditions are evaluated against during a scan, built from
|
|
5
|
+
/// the CLI's `--platform` and `--define` options. The scan is static: it knows the target OS and
|
|
6
|
+
/// the spelled compilation flags, and nothing else. Every condition it cannot answer throws, which
|
|
7
|
+
/// SwiftIfConfig turns into an inactive region plus a diagnostic, so an unanswerable `#if` skips
|
|
8
|
+
/// its declarations and surfaces a warning instead of guessing.
|
|
9
|
+
struct ScanBuildConfiguration: BuildConfiguration {
|
|
10
|
+
/// The target OS name to answer `os(...)` with, as spelled in the condition (`iOS`, `macOS`,
|
|
11
|
+
/// `tvOS`, `watchOS`, `visionOS`; compared case-insensitively). A scan without a target OS runs
|
|
12
|
+
/// without a configuration at all (the platform-agnostic union scan), so this is always known.
|
|
13
|
+
let platform: String
|
|
14
|
+
|
|
15
|
+
/// The conditional compilation flags treated as set, from repeated `--define` options.
|
|
16
|
+
let defines: Set<String>
|
|
17
|
+
|
|
18
|
+
func isCustomConditionSet(name: String) throws -> Bool {
|
|
19
|
+
return defines.contains(name)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func isActiveTargetOS(name: String) throws -> Bool {
|
|
23
|
+
return name.lowercased() == platform.lowercased()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// MARK: - Unanswerable conditions
|
|
27
|
+
|
|
28
|
+
// These vary within a single platform's build (device vs simulator, arm64 vs x86_64) or depend
|
|
29
|
+
// on the consumer's toolchain, so a static scan has no correct answer. Throwing makes the region
|
|
30
|
+
// inactive and emits a warning naming the condition.
|
|
31
|
+
|
|
32
|
+
func hasFeature(name: String) throws -> Bool {
|
|
33
|
+
throw ScanConfigurationError("cannot evaluate 'hasFeature(\(name))' in a static scan")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func hasAttribute(name: String) throws -> Bool {
|
|
37
|
+
throw ScanConfigurationError("cannot evaluate 'hasAttribute(\(name))' in a static scan")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func canImport(importPath: [(TokenSyntax, String)], version: CanImportVersion) throws -> Bool {
|
|
41
|
+
let module = importPath.map(\.1).joined(separator: ".")
|
|
42
|
+
|
|
43
|
+
// A curated set of SDK frameworks is answerable from the target platform alone. Everything
|
|
44
|
+
// else (arbitrary modules, submodule paths, versioned checks) stays unanswerable: a wrong
|
|
45
|
+
// "yes" would surface a declaration that doesn't exist in the real build.
|
|
46
|
+
guard importPath.count == 1,
|
|
47
|
+
case .unversioned = version,
|
|
48
|
+
let frameworkPlatforms = sdkFrameworkPlatforms[module] else {
|
|
49
|
+
throw ScanConfigurationError("cannot evaluate 'canImport(\(module))' in a static scan")
|
|
50
|
+
}
|
|
51
|
+
return frameworkPlatforms.contains(platform.lowercased())
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func isActiveTargetArchitecture(name: String) throws -> Bool {
|
|
55
|
+
throw ScanConfigurationError("cannot evaluate 'arch(\(name))' in a static scan")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func isActiveTargetEnvironment(name: String) throws -> Bool {
|
|
59
|
+
throw ScanConfigurationError("cannot evaluate 'targetEnvironment(\(name))' in a static scan")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func isActiveTargetRuntime(name: String) throws -> Bool {
|
|
63
|
+
throw ScanConfigurationError("cannot evaluate '_runtime(\(name))' in a static scan")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func isActiveTargetPointerAuthentication(name: String) throws -> Bool {
|
|
67
|
+
throw ScanConfigurationError("cannot evaluate '_ptrauth(\(name))' in a static scan")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// MARK: - Fixed answers
|
|
71
|
+
|
|
72
|
+
// Non-throwing protocol requirements, so they need a value. These are constant across Apple
|
|
73
|
+
// targets (the only ones Expo modules compile for), except the versions, which assume a current
|
|
74
|
+
// toolchain; a module class gated on a *lower* Swift version would be wrongly included, which is
|
|
75
|
+
// rare enough to accept for a scan.
|
|
76
|
+
|
|
77
|
+
var targetPointerBitWidth: Int { 64 }
|
|
78
|
+
var targetAtomicBitWidths: [Int] { [32, 64, 128] }
|
|
79
|
+
var endianness: Endianness { .little }
|
|
80
|
+
var languageVersion: VersionTuple { VersionTuple(6) }
|
|
81
|
+
var compilerVersion: VersionTuple { VersionTuple(6, 2) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// The platforms (lowercased, as compared against `--platform`) that ship each of a curated set of
|
|
85
|
+
/// Apple SDK frameworks, so `canImport` of one is answerable from the platform alone. The list is
|
|
86
|
+
/// deliberately small and high-confidence: it covers the frameworks realistically used to gate a
|
|
87
|
+
/// module class, and a framework missing here degrades to the skip-with-warning path rather than a
|
|
88
|
+
/// wrong answer.
|
|
89
|
+
private let sdkFrameworkPlatforms: [String: Set<String>] = [
|
|
90
|
+
"UIKit": ["ios", "tvos", "watchos", "visionos"],
|
|
91
|
+
"AppKit": ["macos"],
|
|
92
|
+
"SwiftUI": ["ios", "macos", "tvos", "watchos", "visionos"],
|
|
93
|
+
"WatchKit": ["watchos"],
|
|
94
|
+
"TVUIKit": ["tvos"],
|
|
95
|
+
"WebKit": ["ios", "macos", "visionos"],
|
|
96
|
+
"SafariServices": ["ios", "macos", "visionos"],
|
|
97
|
+
"ARKit": ["ios", "visionos"],
|
|
98
|
+
"RealityKit": ["ios", "macos", "visionos"],
|
|
99
|
+
"CarPlay": ["ios"],
|
|
100
|
+
"MessageUI": ["ios"],
|
|
101
|
+
"CoreNFC": ["ios"],
|
|
102
|
+
"HealthKit": ["ios", "watchos", "visionos"],
|
|
103
|
+
"HomeKit": ["ios", "tvos", "watchos", "visionos"],
|
|
104
|
+
"WidgetKit": ["ios", "macos", "watchos", "visionos"],
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
/// An unanswerable `#if` condition. SwiftIfConfig converts the thrown error into a diagnostic on
|
|
108
|
+
/// the condition's node and treats the region as inactive.
|
|
109
|
+
struct ScanConfigurationError: Error, CustomStringConvertible {
|
|
110
|
+
let description: String
|
|
111
|
+
|
|
112
|
+
init(_ description: String) {
|
|
113
|
+
self.description = description
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import SwiftParser
|
|
3
|
+
import SwiftSyntax
|
|
4
|
+
|
|
5
|
+
/// Walks `paths`, and for each `.swift` file that might contain one of `macros` or one of
|
|
6
|
+
/// `conformances` (the pre-filter passes it), reads the source and hands it to `process` along with the
|
|
7
|
+
/// file path. Returns the run's stats.
|
|
8
|
+
///
|
|
9
|
+
/// The shared core every scan command builds on: the walk, read, pre-filter, and stats are identical;
|
|
10
|
+
/// only what each command does per parsed file differs (`scan-modules` collects `Detection`s,
|
|
11
|
+
/// `scan-exports` walks a `SurfaceVisitor`), and that lives in `process`.
|
|
12
|
+
func scanFiles(
|
|
13
|
+
paths: [String],
|
|
14
|
+
macros: Set<DetectedMacro>,
|
|
15
|
+
conformances: Set<String> = [],
|
|
16
|
+
process: (_ source: String, _ file: String) -> Void
|
|
17
|
+
) -> ScanStats {
|
|
18
|
+
let clock = ContinuousClock()
|
|
19
|
+
let start = clock.now
|
|
20
|
+
|
|
21
|
+
var filesScanned = 0
|
|
22
|
+
var filesParsed = 0
|
|
23
|
+
|
|
24
|
+
// Compile the pre-filter regex once per run, not once per file.
|
|
25
|
+
let prefilter = macroAttributeRegex(for: macros, conformances: conformances)
|
|
26
|
+
|
|
27
|
+
for file in swiftFiles(in: paths) {
|
|
28
|
+
guard let source = try? String(contentsOfFile: file, encoding: .utf8) else {
|
|
29
|
+
FileHandle.standardError.write(Data("warning: could not read \(file)\n".utf8))
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
filesScanned += 1
|
|
33
|
+
// Skip the (relatively expensive) parse for files that can't contain any of the macros. A plain
|
|
34
|
+
// substring scan is far cheaper than a full parse, and most files in a large tree mention none
|
|
35
|
+
// of these names. See `mightContainMacro` for why this never drops a real match.
|
|
36
|
+
guard mightContainMacro(in: source, prefilter: prefilter) else {
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
filesParsed += 1
|
|
40
|
+
process(source, file)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let elapsed = (clock.now - start).components
|
|
44
|
+
let durationMs = Double(elapsed.seconds) * 1000 + Double(elapsed.attoseconds) / 1e15
|
|
45
|
+
|
|
46
|
+
return ScanStats(filesScanned: filesScanned, filesParsed: filesParsed, durationMs: durationMs)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Walks `paths`, parses each `.swift` file that might contain one of `macros`, and returns every
|
|
50
|
+
/// detection (in file then source order) with the accumulated `#if` warnings and the run's stats —
|
|
51
|
+
/// the shape `scan-modules` projects. A thin layer over `scanFiles` that accumulates the per-file
|
|
52
|
+
/// results.
|
|
53
|
+
func collectDetections(
|
|
54
|
+
paths: [String],
|
|
55
|
+
macros: Set<DetectedMacro>,
|
|
56
|
+
configuration: ScanBuildConfiguration? = nil
|
|
57
|
+
) -> (detections: [Detection], warnings: [ScanWarning], stats: ScanStats) {
|
|
58
|
+
var detections: [Detection] = []
|
|
59
|
+
var warnings: [ScanWarning] = []
|
|
60
|
+
let stats = scanFiles(paths: paths, macros: macros) { source, file in
|
|
61
|
+
let result = detect(source: source, file: file, macros: macros, configuration: configuration)
|
|
62
|
+
detections.append(contentsOf: result.detections)
|
|
63
|
+
warnings.append(contentsOf: result.warnings)
|
|
64
|
+
}
|
|
65
|
+
return (detections, warnings, stats)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Parses one source string and returns its detections for the given macro set, plus the warnings
|
|
69
|
+
/// for `#if` conditions the configuration couldn't answer. The unit of work the tests exercise.
|
|
70
|
+
func detect(
|
|
71
|
+
source: String,
|
|
72
|
+
file: String,
|
|
73
|
+
macros: Set<DetectedMacro>,
|
|
74
|
+
configuration: ScanBuildConfiguration?
|
|
75
|
+
) -> (detections: [Detection], warnings: [ScanWarning]) {
|
|
76
|
+
let tree = Parser.parse(source: source)
|
|
77
|
+
let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: macros, configuration: configuration)
|
|
78
|
+
visitor.walk(tree)
|
|
79
|
+
return (visitor.detections, visitor.warnings)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// MARK: - Pre-filter
|
|
83
|
+
|
|
84
|
+
/// Builds the pre-filter regex for a macro set, e.g. `@(ExpoModule)` for a `modules` scan or
|
|
85
|
+
/// `@(ExpoModule|JS|Record|SharedObject)` for an `exports` scan. A precompiled `NSRegularExpression`
|
|
86
|
+
/// benchmarked ~20x faster over a large source tree than calling `String.contains` once per macro
|
|
87
|
+
/// name, because it scans each file in a single pass. Compiled once per run and reused per file.
|
|
88
|
+
///
|
|
89
|
+
/// `conformances` adds bare (unprefixed) alternatives for types recognized by conformance rather than
|
|
90
|
+
/// by an attribute (`Enumerable` for `scan-exports`). They join the same alternation so the scan stays
|
|
91
|
+
/// one pass; a bare name matches more loosely than an `@`-prefixed one, which costs a wasted parse and
|
|
92
|
+
/// never a miss.
|
|
93
|
+
func macroAttributeRegex(
|
|
94
|
+
for macros: Set<DetectedMacro>,
|
|
95
|
+
conformances: Set<String> = []
|
|
96
|
+
) -> NSRegularExpression {
|
|
97
|
+
// Sort for a stable pattern regardless of the set's iteration order.
|
|
98
|
+
let attributes = macros.map(\.rawValue).sorted().joined(separator: "|")
|
|
99
|
+
var alternatives: [String] = []
|
|
100
|
+
if !attributes.isEmpty {
|
|
101
|
+
alternatives.append("@(\(attributes))")
|
|
102
|
+
}
|
|
103
|
+
alternatives.append(contentsOf: conformances.sorted())
|
|
104
|
+
return try! NSRegularExpression(pattern: alternatives.joined(separator: "|"))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// True if the source text contains one of the pre-filter's spelled macro attributes, so it's worth
|
|
108
|
+
/// parsing. A deliberate over-approximation: the pattern can still match inside a comment or string,
|
|
109
|
+
/// in which case the file is parsed and correctly yields no detections — a wasted parse, never a
|
|
110
|
+
/// missed module. It assumes the attribute is written with no space after `@` (`@ExpoModule`, not
|
|
111
|
+
/// `@ ExpoModule`), which is universal in practice; the rare spaced form would be skipped.
|
|
112
|
+
func mightContainMacro(in source: String, prefilter: NSRegularExpression) -> Bool {
|
|
113
|
+
let range = NSRange(source.startIndex..., in: source)
|
|
114
|
+
return prefilter.firstMatch(in: source, range: range) != nil
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// MARK: - File discovery
|
|
118
|
+
|
|
119
|
+
/// Directory names skipped during the recursive walk, in two groups:
|
|
120
|
+
/// - Build products, dependencies, and git internals (`.build`, `Pods`, `.git`, `node_modules`) are
|
|
121
|
+
/// never source worth scanning, and pruning them keeps the walk from descending into the bulk of
|
|
122
|
+
/// a monorepo's files. `node_modules` also makes any npm package root safe to pass as a scan
|
|
123
|
+
/// path: nested dependencies are separate packages and get scanned on their own.
|
|
124
|
+
/// - Test and example directories, by the layout conventions of Expo module packages (`Tests`,
|
|
125
|
+
/// `UITests`, `__tests__`, `__mocks__`, `example(s)`, `e2e`). Their sources are not compiled into the
|
|
126
|
+
/// package's product (they belong to a `test_spec` or a standalone example app), so a declaration
|
|
127
|
+
/// found there would name a type the consumer can't reference. This is a name-based heuristic;
|
|
128
|
+
/// a package keeping product sources in such a directory can declare its modules in
|
|
129
|
+
/// `expo-module.config.json` instead.
|
|
130
|
+
private let prunedDirectoryNames: Set<String> = [
|
|
131
|
+
".build", "Pods", ".git", "node_modules",
|
|
132
|
+
"Tests", "UITests", "__tests__", "__mocks__", "example", "examples", "e2e",
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
/// Expands the given paths into the list of `.swift` files to parse: a file path passes through,
|
|
136
|
+
/// a directory is enumerated recursively (skipping `prunedDirectoryNames`). Order is deterministic
|
|
137
|
+
/// so output is stable across runs.
|
|
138
|
+
///
|
|
139
|
+
/// Reported paths are absolute, so the output is unambiguous and independent of the caller's working
|
|
140
|
+
/// directory. (A future `--root` option could emit paths relative to a given base when a portable,
|
|
141
|
+
/// shorter form is wanted.)
|
|
142
|
+
func swiftFiles(in paths: [String]) -> [String] {
|
|
143
|
+
let fileManager = FileManager.default
|
|
144
|
+
var result: [String] = []
|
|
145
|
+
|
|
146
|
+
for path in paths {
|
|
147
|
+
var isDirectory: ObjCBool = false
|
|
148
|
+
guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else {
|
|
149
|
+
FileHandle.standardError.write(Data("warning: no such path \(path)\n".utf8))
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if isDirectory.boolValue {
|
|
154
|
+
result.append(contentsOf: swiftFiles(inDirectory: URL(fileURLWithPath: path), fileManager: fileManager))
|
|
155
|
+
} else if path.hasSuffix(".swift") {
|
|
156
|
+
// A directory walk already yields absolute paths; resolve a directly-passed file the same way
|
|
157
|
+
// so every reported path is absolute regardless of how it was spelled.
|
|
158
|
+
result.append(URL(fileURLWithPath: path).standardizedFileURL.path)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result.sorted()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Recursively enumerates `.swift` files under a directory, calling `skipDescendants()` on any
|
|
166
|
+
/// pruned directory so its subtree is never read. Uses the URL enumerator (rather than the
|
|
167
|
+
/// path-based one) precisely because it supports skipping a subtree mid-walk.
|
|
168
|
+
///
|
|
169
|
+
/// Directory-ness is read from `hasDirectoryPath` (the enumerator sets a trailing slash on the URLs
|
|
170
|
+
/// it yields) rather than `resourceValues(forKeys: [.isDirectoryKey])`, which re-`stat`s each entry.
|
|
171
|
+
/// The walk is the dominant cost of a whole-tree scan, and skipping that per-entry stat measurably
|
|
172
|
+
/// shortens it.
|
|
173
|
+
private func swiftFiles(inDirectory directory: URL, fileManager: FileManager) -> [String] {
|
|
174
|
+
guard let enumerator = fileManager.enumerator(
|
|
175
|
+
at: directory,
|
|
176
|
+
includingPropertiesForKeys: nil,
|
|
177
|
+
options: [.skipsHiddenFiles]
|
|
178
|
+
) else {
|
|
179
|
+
return []
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
var result: [String] = []
|
|
183
|
+
for case let url as URL in enumerator {
|
|
184
|
+
if url.hasDirectoryPath {
|
|
185
|
+
if prunedDirectoryNames.contains(url.lastPathComponent) {
|
|
186
|
+
enumerator.skipDescendants()
|
|
187
|
+
}
|
|
188
|
+
} else if url.pathExtension == "swift" {
|
|
189
|
+
result.append(url.path)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return result
|
|
193
|
+
}
|