@expo/expo-modules-macros-plugin 0.8.0 → 0.10.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.
@@ -0,0 +1,121 @@
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), or `nil` when no `--platform`
12
+ /// was given, in which case `os(...)` conditions are unanswerable.
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
+ guard let platform else {
24
+ throw ScanConfigurationError("cannot evaluate 'os(\(name))': no --platform was given")
25
+ }
26
+ return name.lowercased() == platform.lowercased()
27
+ }
28
+
29
+ // MARK: - Unanswerable conditions
30
+
31
+ // These vary within a single platform's build (device vs simulator, arm64 vs x86_64) or depend
32
+ // on the consumer's toolchain, so a static scan has no correct answer. Throwing makes the region
33
+ // inactive and emits a warning naming the condition.
34
+
35
+ func hasFeature(name: String) throws -> Bool {
36
+ throw ScanConfigurationError("cannot evaluate 'hasFeature(\(name))' in a static scan")
37
+ }
38
+
39
+ func hasAttribute(name: String) throws -> Bool {
40
+ throw ScanConfigurationError("cannot evaluate 'hasAttribute(\(name))' in a static scan")
41
+ }
42
+
43
+ func canImport(importPath: [(TokenSyntax, String)], version: CanImportVersion) throws -> Bool {
44
+ let module = importPath.map(\.1).joined(separator: ".")
45
+
46
+ // A curated set of SDK frameworks is answerable from the target platform alone. Everything
47
+ // else (arbitrary modules, submodule paths, versioned checks) stays unanswerable: a wrong
48
+ // "yes" would surface a declaration that doesn't exist in the real build.
49
+ guard importPath.count == 1,
50
+ case .unversioned = version,
51
+ let frameworkPlatforms = sdkFrameworkPlatforms[module] else {
52
+ throw ScanConfigurationError("cannot evaluate 'canImport(\(module))' in a static scan")
53
+ }
54
+ guard let platform else {
55
+ throw ScanConfigurationError("cannot evaluate 'canImport(\(module))': no --platform was given")
56
+ }
57
+ return frameworkPlatforms.contains(platform.lowercased())
58
+ }
59
+
60
+ func isActiveTargetArchitecture(name: String) throws -> Bool {
61
+ throw ScanConfigurationError("cannot evaluate 'arch(\(name))' in a static scan")
62
+ }
63
+
64
+ func isActiveTargetEnvironment(name: String) throws -> Bool {
65
+ throw ScanConfigurationError("cannot evaluate 'targetEnvironment(\(name))' in a static scan")
66
+ }
67
+
68
+ func isActiveTargetRuntime(name: String) throws -> Bool {
69
+ throw ScanConfigurationError("cannot evaluate '_runtime(\(name))' in a static scan")
70
+ }
71
+
72
+ func isActiveTargetPointerAuthentication(name: String) throws -> Bool {
73
+ throw ScanConfigurationError("cannot evaluate '_ptrauth(\(name))' in a static scan")
74
+ }
75
+
76
+ // MARK: - Fixed answers
77
+
78
+ // Non-throwing protocol requirements, so they need a value. These are constant across Apple
79
+ // targets (the only ones Expo modules compile for), except the versions, which assume a current
80
+ // toolchain; a module class gated on a *lower* Swift version would be wrongly included, which is
81
+ // rare enough to accept for a scan.
82
+
83
+ var targetPointerBitWidth: Int { 64 }
84
+ var targetAtomicBitWidths: [Int] { [32, 64, 128] }
85
+ var endianness: Endianness { .little }
86
+ var languageVersion: VersionTuple { VersionTuple(6) }
87
+ var compilerVersion: VersionTuple { VersionTuple(6, 2) }
88
+ }
89
+
90
+ /// The platforms (lowercased, as compared against `--platform`) that ship each of a curated set of
91
+ /// Apple SDK frameworks, so `canImport` of one is answerable from the platform alone. The list is
92
+ /// deliberately small and high-confidence: it covers the frameworks realistically used to gate a
93
+ /// module class, and a framework missing here degrades to the skip-with-warning path rather than a
94
+ /// wrong answer.
95
+ private let sdkFrameworkPlatforms: [String: Set<String>] = [
96
+ "UIKit": ["ios", "tvos", "watchos", "visionos"],
97
+ "AppKit": ["macos"],
98
+ "SwiftUI": ["ios", "macos", "tvos", "watchos", "visionos"],
99
+ "WatchKit": ["watchos"],
100
+ "TVUIKit": ["tvos"],
101
+ "WebKit": ["ios", "macos", "visionos"],
102
+ "SafariServices": ["ios", "macos", "visionos"],
103
+ "ARKit": ["ios", "visionos"],
104
+ "RealityKit": ["ios", "macos", "visionos"],
105
+ "CarPlay": ["ios"],
106
+ "MessageUI": ["ios"],
107
+ "CoreNFC": ["ios"],
108
+ "HealthKit": ["ios", "watchos", "visionos"],
109
+ "HomeKit": ["ios", "tvos", "watchos", "visionos"],
110
+ "WidgetKit": ["ios", "macos", "watchos", "visionos"],
111
+ ]
112
+
113
+ /// An unanswerable `#if` condition. SwiftIfConfig converts the thrown error into a diagnostic on
114
+ /// the condition's node and treats the region as inactive.
115
+ struct ScanConfigurationError: Error, CustomStringConvertible {
116
+ let description: String
117
+
118
+ init(_ description: String) {
119
+ self.description = description
120
+ }
121
+ }
@@ -44,23 +44,36 @@ func scanFiles(
44
44
  }
45
45
 
46
46
  /// Walks `paths`, parses each `.swift` file that might contain one of `macros`, and returns every
47
- /// detection (in file then source order) with the run's stats — the shape `scan-modules` projects.
48
- /// A thin layer over `scanFiles` that accumulates the per-file detections.
49
- func collectDetections(paths: [String], macros: Set<DetectedMacro>) -> (detections: [Detection], stats: ScanStats) {
47
+ /// detection (in file then source order) with the accumulated `#if` warnings and the run's stats —
48
+ /// the shape `scan-modules` projects. A thin layer over `scanFiles` that accumulates the per-file
49
+ /// results.
50
+ func collectDetections(
51
+ paths: [String],
52
+ macros: Set<DetectedMacro>,
53
+ configuration: ScanBuildConfiguration = .init(platform: nil, defines: [])
54
+ ) -> (detections: [Detection], warnings: [ScanWarning], stats: ScanStats) {
50
55
  var detections: [Detection] = []
56
+ var warnings: [ScanWarning] = []
51
57
  let stats = scanFiles(paths: paths, macros: macros) { source, file in
52
- detections.append(contentsOf: detect(source: source, file: file, macros: macros))
58
+ let result = detect(source: source, file: file, macros: macros, configuration: configuration)
59
+ detections.append(contentsOf: result.detections)
60
+ warnings.append(contentsOf: result.warnings)
53
61
  }
54
- return (detections, stats)
62
+ return (detections, warnings, stats)
55
63
  }
56
64
 
57
- /// Parses one source string and returns its detections for the given macro set. The unit of work the
58
- /// tests exercise.
59
- func detect(source: String, file: String, macros: Set<DetectedMacro>) -> [Detection] {
65
+ /// Parses one source string and returns its detections for the given macro set, plus the warnings
66
+ /// for `#if` conditions the configuration couldn't answer. The unit of work the tests exercise.
67
+ func detect(
68
+ source: String,
69
+ file: String,
70
+ macros: Set<DetectedMacro>,
71
+ configuration: ScanBuildConfiguration
72
+ ) -> (detections: [Detection], warnings: [ScanWarning]) {
60
73
  let tree = Parser.parse(source: source)
61
- let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: macros)
74
+ let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: macros, configuration: configuration)
62
75
  visitor.walk(tree)
63
- return visitor.detections
76
+ return (visitor.detections, visitor.warnings)
64
77
  }
65
78
 
66
79
  // MARK: - Pre-filter
@@ -87,10 +100,21 @@ func mightContainMacro(in source: String, prefilter: NSRegularExpression) -> Boo
87
100
 
88
101
  // MARK: - File discovery
89
102
 
90
- /// Directory names skipped during the recursive walk. These hold build products, dependencies, and
91
- /// git internals never source worth scanning — and pruning them keeps the walk from descending
92
- /// into the bulk of a monorepo's files.
93
- private let prunedDirectoryNames: Set<String> = [".build", "Pods", ".git"]
103
+ /// Directory names skipped during the recursive walk, in two groups:
104
+ /// - Build products, dependencies, and git internals (`.build`, `Pods`, `.git`, `node_modules`) are
105
+ /// never source worth scanning, and pruning them keeps the walk from descending into the bulk of
106
+ /// a monorepo's files. `node_modules` also makes any npm package root safe to pass as a scan
107
+ /// path: nested dependencies are separate packages and get scanned on their own.
108
+ /// - Test and example directories, by the layout conventions of Expo module packages (`Tests`,
109
+ /// `UITests`, `__tests__`, `__mocks__`, `example(s)`, `e2e`). Their sources are not compiled into the
110
+ /// package's product (they belong to a `test_spec` or a standalone example app), so a declaration
111
+ /// found there would name a type the consumer can't reference. This is a name-based heuristic;
112
+ /// a package keeping product sources in such a directory can declare its modules in
113
+ /// `expo-module.config.json` instead.
114
+ private let prunedDirectoryNames: Set<String> = [
115
+ ".build", "Pods", ".git", "node_modules",
116
+ "Tests", "UITests", "__tests__", "__mocks__", "example", "examples", "e2e",
117
+ ]
94
118
 
95
119
  /// Expands the given paths into the list of `.swift` files to parse: a file path passes through,
96
120
  /// a directory is enumerated recursively (skipping `prunedDirectoryNames`). Order is deterministic
@@ -9,8 +9,12 @@ public enum Scanner {
9
9
  /// Runs the `scan-modules` command over `paths`, prints the JSON report to stdout, and returns a
10
10
  /// process exit code: `0` on success, `1` if encoding fails. (The deep `scan-exports` command has
11
11
  /// its own `runExports` entry returning its own result type.)
12
- public static func runModules(paths: [String]) -> Int32 {
13
- let result = scanModules(paths: paths)
12
+ ///
13
+ /// `platform` and `defines` (the `--platform` and `--define` options) form the configuration that
14
+ /// `#if` conditions are evaluated against; see `ScanBuildConfiguration`.
15
+ public static func runModules(paths: [String], platform: String? = nil, defines: [String] = []) -> Int32 {
16
+ let configuration = ScanBuildConfiguration(platform: platform, defines: Set(defines))
17
+ let result = scanModules(paths: paths, configuration: configuration)
14
18
 
15
19
  do {
16
20
  let encoder = JSONEncoder()
@@ -40,29 +44,51 @@ struct ScannedModule: Codable, Equatable {
40
44
  /// and the consumer never has to apply the fallback itself.
41
45
  let jsName: String
42
46
 
47
+ /// The class's spelled access modifier (`open`, `public`, `package`, `fileprivate`, `private`),
48
+ /// or `internal` when none is written. The generated modules provider references the class from
49
+ /// the app target, which requires `public`/`open`, so the consumer uses this to skip inaccessible
50
+ /// classes with a diagnostic instead of emitting a provider that fails to compile.
51
+ let accessLevel: String
52
+
43
53
  /// Source file the module was found in, relative to the path the scanner was invoked with.
44
54
  let file: String
45
55
  }
46
56
 
57
+ /// Version of the `scan-modules` output shape. Bumped on any breaking change to the envelope or to
58
+ /// `ScannedModule`, so `expo-modules-autolinking` can verify it understands the output before
59
+ /// trusting it (and fall back to config-declared modules when it doesn't).
60
+ let scanModulesSchemaVersion = 1
61
+
47
62
  /// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the
48
63
  /// command's JSON output. (`scan-exports` returns its own `ScanExportsResult` shape; the two commands
49
64
  /// serve different consumers and don't share an envelope.)
50
65
  struct ScanModulesResult: Codable, Equatable {
66
+ let schemaVersion: Int
51
67
  let modules: [ScannedModule]
68
+
69
+ /// Warnings for `#if` conditions the scan couldn't answer statically (see `ScanWarning`). Carried
70
+ /// in the report rather than on stderr so the consumer can attach them to its own output.
71
+ let warnings: [ScanWarning]
72
+
52
73
  let stats: ScanStats
53
74
  }
54
75
 
55
76
  /// Scans the given paths for top-level `@ExpoModule` types and returns the modules (in file then
56
77
  /// source order) plus the stats for the run — the `scan-modules` command. Kept separate from the
57
78
  /// public entry (and `internal`) so tests can drive it without going through argv/stdout.
58
- func scanModules(paths: [String]) -> ScanModulesResult {
59
- let scan = collectDetections(paths: paths, macros: [.expoModule])
79
+ func scanModules(paths: [String], configuration: ScanBuildConfiguration = .init(platform: nil, defines: [])) -> ScanModulesResult {
80
+ let scan = collectDetections(paths: paths, macros: [.expoModule], configuration: configuration)
60
81
 
61
82
  let modules = scan.detections.map {
62
83
  // Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` override, else the
63
84
  // class name.
64
- ScannedModule(name: $0.name, jsName: $0.jsName ?? $0.name, file: $0.file)
85
+ ScannedModule(name: $0.name, jsName: $0.jsName ?? $0.name, accessLevel: $0.accessLevel, file: $0.file)
65
86
  }
66
87
 
67
- return ScanModulesResult(modules: modules, stats: scan.stats)
88
+ return ScanModulesResult(
89
+ schemaVersion: scanModulesSchemaVersion,
90
+ modules: modules,
91
+ warnings: scan.warnings,
92
+ stats: scan.stats
93
+ )
68
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/expo-modules-macros-plugin",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Swift macro plugin for Expo modules",
5
5
  "license": "MIT",
6
6
  "author": "650 Industries, Inc.",
@@ -1,71 +0,0 @@
1
- import ExpoModulesScanner
2
- import Foundation
3
-
4
- /// Command-line front end for the scanner. Parses the subcommand and paths, then delegates to the
5
- /// matching library entry (which runs the scan and writes its JSON output). Each path may be a
6
- /// `.swift` file or a directory (scanned recursively for `.swift` files).
7
- ///
8
- /// Subcommands:
9
- /// scan-modules <path>... fast: top-level `@ExpoModule` types, for autolinking
10
- /// scan-exports <path>... deep: full JS-exported surface, for TS type generation
11
-
12
- let toolName = "ExpoModulesScanner"
13
-
14
- let usageText = """
15
- usage: \(toolName) <subcommand> <path> [<path> ...]
16
-
17
- subcommands:
18
- scan-modules fast scan for top-level @ExpoModule types (autolinking)
19
- scan-exports deep scan of the full JS-exported surface (type generation)
20
-
21
- options:
22
- -h, --help print this help and exit
23
-
24
- """
25
-
26
- /// Prints the usage text to the given handle. Goes to stdout when help was explicitly requested
27
- /// (a successful action), stderr when it accompanies a usage error.
28
- func printUsage(to handle: FileHandle = .standardError) {
29
- handle.write(Data(usageText.utf8))
30
- }
31
-
32
- func fail(_ message: String, usage: Bool = false, code: Int32 = 2) -> Never {
33
- FileHandle.standardError.write(Data("error: \(message)\n".utf8))
34
- if usage {
35
- printUsage()
36
- }
37
- exit(code)
38
- }
39
-
40
- var arguments = Array(CommandLine.arguments.dropFirst())
41
-
42
- // `-h`/`--help` anywhere is treated as a help request: print usage to stdout and exit 0.
43
- if arguments.contains(where: { $0 == "-h" || $0 == "--help" }) {
44
- printUsage(to: .standardOutput)
45
- exit(0)
46
- }
47
-
48
- guard !arguments.isEmpty else {
49
- printUsage()
50
- exit(2)
51
- }
52
-
53
- let subcommand = arguments.removeFirst()
54
- let paths = arguments
55
-
56
- switch subcommand {
57
- case "scan-modules":
58
- guard !paths.isEmpty else {
59
- fail("scan-modules requires at least one path", usage: true)
60
- }
61
- exit(Scanner.runModules(paths: paths))
62
-
63
- case "scan-exports":
64
- guard !paths.isEmpty else {
65
- fail("scan-exports requires at least one path", usage: true)
66
- }
67
- exit(Scanner.runExports(paths: paths))
68
-
69
- default:
70
- fail("unknown subcommand '\(subcommand)'", usage: true)
71
- }