@expo/expo-modules-macros-plugin 0.5.1 → 0.6.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.
@@ -1,19 +1,33 @@
1
1
  import SwiftSyntax
2
2
 
3
3
  /// The protocol that every type crossing the JS boundary must conform to. Centralized here so the
4
- /// eventual rename (this is a placeholder name) is a single edit, and shared by every macro that
5
- /// asserts the conformance (`@JS`, `@Record`, …).
4
+ /// eventual rename (this is a placeholder name) is a single edit, and shared by the macros whose
5
+ /// generated conversions go through the dynamic-type API (`@Record`, `@Event`).
6
6
  internal let jsConvertibleProtocolName = "AnyArgument"
7
7
 
8
+ /// The constraint a `@JS` boundary type must satisfy, by direction: an argument (and a settable
9
+ /// property's incoming value) is decoded, so it must be `JavaScriptDecodable`; a return value (and a
10
+ /// property's outgoing value) is encoded, so it must be `JavaScriptEncodable`. Asserting each
11
+ /// direction on its own keeps a decode-only or encode-only type from being over-constrained, and
12
+ /// surfaces a missing conformance as a clear "requires that '…' conform to …" diagnostic on the
13
+ /// member instead of an opaque error inside the generated closure.
14
+ internal let javaScriptDecodableProtocolName = "JavaScriptDecodable"
15
+ internal let javaScriptEncodableProtocolName = "JavaScriptEncodable"
16
+
17
+ /// The constraint a `@Record` property type must satisfy: it must be both `AnyArgument` (the
18
+ /// `from(dictionary:)` / `toDictionary(appContext:)` paths still convert native `Any` through the
19
+ /// dynamic-type API) and `JavaScriptDecodable & JavaScriptEncodable` (the `from(object:)` /
20
+ /// `toObject(appContext:)` paths convert JS values through `decode`/`encode`). A record field has to
21
+ /// support both directions, so the assertion requires the intersection.
22
+ internal let recordFieldProtocolName = "AnyArgument & JavaScriptDecodable & JavaScriptEncodable"
23
+
8
24
  /// The protocol a type must conform to for `self.emit(event:…)` to resolve; core conforms
9
25
  /// `BaseModule` and `SharedObject` to it. Asserted by `@Event` so attaching it to a type that can't
10
26
  /// emit fails with a conformance diagnostic instead of an opaque "no member 'emit'" error.
11
27
  internal let eventEmitterProtocolName = "EventEmitter"
12
28
 
13
- /// Types we never assert because they're statically known to conform and never reach the dynamic
14
- /// converter: the JS primitives. Asserting them would only add noise to the expansion. Kept here
15
- /// (rather than reusing the decode-path's `fastDecodeAccessor`) because "known-to-conform" is a
16
- /// concept that belongs with the assertion logic, not with how a value is decoded.
29
+ /// Types we never assert because they're statically known to conform: the JS primitives. Asserting
30
+ /// them would only add noise to the expansion.
17
31
  private let knownConformingPrimitives: Set<String> = ["Bool", "Int", "Double", "String"]
18
32
 
19
33
  /// One member's worth of conformance assertion: a name (the member it stands for) and the declared
@@ -27,27 +41,55 @@ internal struct ConformanceAssertion {
27
41
  let types: [String]
28
42
  }
29
43
 
30
- /// A single conformance-assertion peer for a `@JS` member: a never-called `private func` whose body
31
- /// statically asserts the member's boundary types conform. The assertion is compile-time only — the
32
- /// function is never invoked, but Swift still type-checks its body, so a non-conforming type becomes
33
- /// a compile error. Emitted as a **peer** of the user's declaration, so that error lands on the
34
- /// user's own member rather than on the enclosing macro.
35
- ///
36
- /// `isStatic` makes the peer `static`, mirroring a `static`/`class` member so it's emitted in the
37
- /// right metatype context (a peer of a type-level member can't be an instance method). `class func`
38
- /// members collapse to `static` here too: the peer is private and never called or overridden, so
39
- /// `static` is always sufficient.
44
+ /// A directional conformance-assertion peer for a `@JS` member: a never-called `private func` whose
45
+ /// body asserts each argument type is `JavaScriptDecodable` and the return/getter type is
46
+ /// `JavaScriptEncodable`, matching how the generated binding converts each (decode on the way in,
47
+ /// encode on the way out). The check is a single member-named nested helper with one `A0…` generic
48
+ /// parameter per non-primitive argument and a single `Return` parameter for the value, called once with
49
+ /// every type's metatype; naming the helper after the member puts the member in the compiler's
50
+ /// diagnostic, and the per-slot constraint reports the offending type against the protocol for *its*
51
+ /// direction.
40
52
  ///
41
- /// Returns `nil` when nothing is left to assert (every type was a known-conforming primitive, or the
42
- /// list was empty), so the caller emits nothing in that case.
43
- internal func typeConformanceAssertion(for assertion: ConformanceAssertion, isStatic: Bool) -> DeclSyntax? {
44
- guard let body = conformanceAssertionBody(assertion) else {
53
+ /// `decodableTypes` are the argument types (and a settable property's value type); `encodableType` is
54
+ /// the single return type (or a property's value type on read), or `nil` when there's none. Primitives
55
+ /// are dropped from each; when nothing is left in either, returns `nil` so the caller emits no peer.
56
+ /// `isStatic` mirrors a `static`/`class` member so the peer sits in the right metatype context.
57
+ internal func directionalConformanceAssertion(
58
+ name: String,
59
+ decodableTypes: [String],
60
+ encodableType: String?,
61
+ isStatic: Bool
62
+ ) -> DeclSyntax? {
63
+ let decodables = distinctAssertableTypes(decodableTypes)
64
+ let encodable = encodableType.flatMap(assertableBoundaryType)
65
+ guard !decodables.isEmpty || encodable != nil else {
45
66
  return nil
46
67
  }
68
+
69
+ // Build the generic parameter list and the matching call arguments in lockstep: one `A0…` slot
70
+ // constrained `JavaScriptDecodable` per argument, and a single `Return` slot constrained
71
+ // `JavaScriptEncodable` for the return/getter value (there's only ever one, so it isn't indexed).
72
+ var parameters: [String] = []
73
+ var typeParameters: [String] = []
74
+ var arguments: [String] = []
75
+ for (index, type) in decodables.enumerated() {
76
+ parameters.append("A\(index): \(javaScriptDecodableProtocolName)")
77
+ typeParameters.append("_: A\(index).Type")
78
+ arguments.append("\(type).self")
79
+ }
80
+ if let encodable {
81
+ parameters.append("Return: \(javaScriptEncodableProtocolName)")
82
+ typeParameters.append("_: Return.Type")
83
+ arguments.append("\(encodable).self")
84
+ }
85
+
86
+ let helper = "func \(name)<\(parameters.joined(separator: ", "))>(\(typeParameters.joined(separator: ", "))) {}"
87
+ let call = "\(name)(\(arguments.joined(separator: ", ")))"
47
88
  let staticKeyword = isStatic ? "static " : ""
48
89
  return """
49
- private \(raw: staticKeyword)func _assertTypesConformance_\(raw: assertion.name)() {
50
- \(raw: body)
90
+ private \(raw: staticKeyword)func _assertTypesConformance_\(raw: name)() {
91
+ \(raw: helper)
92
+ \(raw: call)
51
93
  }
52
94
  """
53
95
  }
@@ -59,8 +101,11 @@ internal func typeConformanceAssertion(for assertion: ConformanceAssertion, isSt
59
101
  ///
60
102
  /// Returns `nil` when no assertion has anything left to verify (all primitives / empty), so the
61
103
  /// caller emits nothing.
62
- internal func typeConformanceAssertions(for assertions: [ConformanceAssertion]) -> DeclSyntax? {
63
- let bodies = assertions.compactMap(conformanceAssertionBody)
104
+ internal func typeConformanceAssertions(
105
+ for assertions: [ConformanceAssertion],
106
+ constraint: String = jsConvertibleProtocolName
107
+ ) -> DeclSyntax? {
108
+ let bodies = assertions.compactMap { conformanceAssertionBody($0, constraint: constraint) }
64
109
  guard !bodies.isEmpty else {
65
110
  return nil
66
111
  }
@@ -85,24 +130,33 @@ internal func assertableBoundaryType(_ type: String) -> String? {
85
130
  /// symbol, nothing to collide, nothing left in the type's namespace — and naming it after the member
86
131
  /// puts the member's name in the compiler's conformance diagnostic. Returns `nil` when every type was
87
132
  /// a known-conforming primitive or the list was empty.
88
- private func conformanceAssertionBody(_ assertion: ConformanceAssertion) -> String? {
89
- // Unwrap top-level optionals to the core type, then dedup so each type is asserted once even when
90
- // it appears more than once; skip known primitives.
91
- var seen: Set<String> = []
92
- var distinct: [String] = []
93
- for type in assertion.types.map(unwrappedOptional)
94
- where !knownConformingPrimitives.contains(type) && seen.insert(type).inserted {
95
- distinct.append(type)
96
- }
133
+ private func conformanceAssertionBody(
134
+ _ assertion: ConformanceAssertion,
135
+ constraint: String = jsConvertibleProtocolName
136
+ ) -> String? {
137
+ let distinct = distinctAssertableTypes(assertion.types)
97
138
  guard !distinct.isEmpty else {
98
139
  return nil
99
140
  }
100
141
 
101
- var lines = ["func \(assertion.name)<T: \(jsConvertibleProtocolName)>(_: T.Type) {}"]
142
+ var lines = ["func \(assertion.name)<T: \(constraint)>(_: T.Type) {}"]
102
143
  lines.append(contentsOf: distinct.map { "\(assertion.name)(\($0).self)" })
103
144
  return lines.joined(separator: "\n")
104
145
  }
105
146
 
147
+ /// Normalizes a list of boundary types for assertion: unwraps each to its core type (trailing
148
+ /// optionals stripped), drops the known-conforming primitives that never need asserting, and dedups
149
+ /// while preserving first-seen order so a type is asserted once even when it appears more than once.
150
+ private func distinctAssertableTypes(_ types: [String]) -> [String] {
151
+ var seen: Set<String> = []
152
+ var distinct: [String] = []
153
+ for type in types.map(unwrappedOptional)
154
+ where !knownConformingPrimitives.contains(type) && seen.insert(type).inserted {
155
+ distinct.append(type)
156
+ }
157
+ return distinct
158
+ }
159
+
106
160
  /// Strips every trailing optional marker (`?`/`!`) so the assertion targets the core wrapped type.
107
161
  /// `Optional<W>: AnyArgument` holds exactly when `W: AnyArgument` (and `T!` is just `T?`), so each
108
162
  /// layer is conformance-equivalent to its wrapped type. Asserting the core gives a cleaner diagnostic
@@ -95,8 +95,9 @@ private func arguments(of attribute: AttributeSyntax) -> [MacroArgument] {
95
95
 
96
96
  /// The first string-literal argument of an attribute, e.g. `@JS("doWork")` -> "doWork". Returns
97
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? {
98
+ /// `jsNameArgument` helper reads inside the macros.) Shared with the `scan-exports` surface visitor,
99
+ /// which reads the same JS-name override off `@JS`/`@ExpoModule`/`@SharedObject`.
100
+ func stringArgument(of attribute: AttributeSyntax) -> String? {
100
101
  guard let args = attribute.arguments?.as(LabeledExprListSyntax.self),
101
102
  let first = args.first,
102
103
  first.label == nil,
@@ -2,14 +2,19 @@ import Foundation
2
2
  import SwiftParser
3
3
  import SwiftSyntax
4
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) {
5
+ /// Walks `paths`, and for each `.swift` file that might contain one of `macros` (the pre-filter passes
6
+ /// it), reads the source and hands it to `process` along with the file path. Returns the run's stats.
7
+ /// The shared core every scan command builds on: the walk, read, pre-filter, and stats are identical;
8
+ /// only what each command does per parsed file differs (`scan-modules` collects `Detection`s,
9
+ /// `scan-exports` walks a `SurfaceVisitor`), and that lives in `process`.
10
+ func scanFiles(
11
+ paths: [String],
12
+ macros: Set<DetectedMacro>,
13
+ process: (_ source: String, _ file: String) -> Void
14
+ ) -> ScanStats {
9
15
  let clock = ContinuousClock()
10
16
  let start = clock.now
11
17
 
12
- var detections: [Detection] = []
13
18
  var filesScanned = 0
14
19
  var filesParsed = 0
15
20
 
@@ -29,13 +34,24 @@ func collectDetections(paths: [String], macros: Set<DetectedMacro>) -> (detectio
29
34
  continue
30
35
  }
31
36
  filesParsed += 1
32
- detections.append(contentsOf: detect(source: source, file: file, macros: macros))
37
+ process(source, file)
33
38
  }
34
39
 
35
40
  let elapsed = (clock.now - start).components
36
41
  let durationMs = Double(elapsed.seconds) * 1000 + Double(elapsed.attoseconds) / 1e15
37
42
 
38
- return (detections, ScanStats(filesScanned: filesScanned, filesParsed: filesParsed, durationMs: durationMs))
43
+ return ScanStats(filesScanned: filesScanned, filesParsed: filesParsed, durationMs: durationMs)
44
+ }
45
+
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) {
50
+ var detections: [Detection] = []
51
+ let stats = scanFiles(paths: paths, macros: macros) { source, file in
52
+ detections.append(contentsOf: detect(source: source, file: file, macros: macros))
53
+ }
54
+ return (detections, stats)
39
55
  }
40
56
 
41
57
  /// Parses one source string and returns its detections for the given macro set. The unit of work the
@@ -0,0 +1,186 @@
1
+ import Foundation
2
+
3
+ /// The deep-scan surface: the full JS-exported shape of every `@ExpoModule`, `@SharedObject`, and
4
+ /// `@Record` type, with the per-member detail a TypeScript type generator needs. Read syntactically
5
+ /// (like the macros): each boundary type becomes a structured `TypeNode` tree so the consumer walks a
6
+ /// tagged tree rather than re-parsing Swift type syntax.
7
+
8
+ /// One parameter of a `@JS` function or `@JS init`.
9
+ struct ExportedParameter: Encodable, Equatable {
10
+ /// The argument label (the `first` name): `to` in `func move(to point: Point)`, or `_` if unlabeled.
11
+ let label: String
12
+
13
+ /// The internal parameter name (the `second` name, else the same as `label`): `point` above.
14
+ let name: String
15
+
16
+ let type: TypeNode
17
+
18
+ /// True when the caller may omit it: a default value or an optional type. Distinct from the type
19
+ /// being `.optional` (a defaulted non-optional is also omittable). Encoded as `optional`.
20
+ let isOptional: Bool
21
+
22
+ private enum CodingKeys: String, CodingKey {
23
+ case label, name, type
24
+ case isOptional = "optional"
25
+ }
26
+ }
27
+
28
+ /// One `@JS func` on a module or shared object.
29
+ struct ExportedFunction: Encodable, Equatable {
30
+ /// The Swift declaration name.
31
+ let name: String
32
+
33
+ /// The JS name it binds under: the `@JS("x")` override, else `name`.
34
+ let jsName: String
35
+
36
+ let parameters: [ExportedParameter]
37
+
38
+ /// The return type, or `nil` for `Void`. Named `returns` to pair with `parameters`.
39
+ let returns: TypeNode?
40
+
41
+ /// `async` (an async function is promise-returning in JS).
42
+ let isAsync: Bool
43
+
44
+ /// `throws`.
45
+ let isThrowing: Bool
46
+
47
+ /// `static`/`class` member.
48
+ let isStatic: Bool
49
+
50
+ /// The flags are encoded under their TS-keyword spellings.
51
+ private enum CodingKeys: String, CodingKey {
52
+ case name, jsName, parameters, returns
53
+ case isAsync = "async"
54
+ case isThrowing = "throws"
55
+ case isStatic = "static"
56
+ }
57
+ }
58
+
59
+ /// One `@JS var` on a module or shared object.
60
+ struct ExportedProperty: Encodable, Equatable {
61
+ /// The Swift declaration name.
62
+ let name: String
63
+
64
+ /// The JS name it binds under: the `@JS("x")` override, else `name`.
65
+ let jsName: String
66
+
67
+ /// The value type, or `nil` when undeterminable syntactically (no annotation and no literal
68
+ /// default), the case the macro binds getter-only.
69
+ let type: TypeNode?
70
+
71
+ /// True when JS can assign to it: a stored `var` or a computed `var` with a `set`. Encoded as its
72
+ /// inverse, `readonly`.
73
+ let isSettable: Bool
74
+
75
+ /// `static`/`class` member. Encoded as `static`.
76
+ let isStatic: Bool
77
+
78
+ private enum CodingKeys: String, CodingKey {
79
+ case name, jsName, type
80
+ case isReadonly = "readonly"
81
+ case isStatic = "static"
82
+ }
83
+
84
+ func encode(to encoder: Encoder) throws {
85
+ var container = encoder.container(keyedBy: CodingKeys.self)
86
+ try container.encode(name, forKey: .name)
87
+ try container.encode(jsName, forKey: .jsName)
88
+ try container.encodeIfPresent(type, forKey: .type)
89
+ try container.encode(!isSettable, forKey: .isReadonly)
90
+ try container.encode(isStatic, forKey: .isStatic)
91
+ }
92
+ }
93
+
94
+ /// One `@Record` property: a plain data slot exposing `optional`/`required` (vs. `ExportedProperty`,
95
+ /// a JS accessor exposing `readonly`/`static`). A record crosses the boundary by value, so the whole
96
+ /// type is read-only in JS; that's a record-level fact and isn't stamped per property.
97
+ struct ExportedRecordProperty: Encodable, Equatable {
98
+ let name: String
99
+
100
+ /// `@Record` requires a determinable type on every property, so this is never `nil`.
101
+ let type: TypeNode
102
+
103
+ /// Optional-typed. Encoded as `optional`.
104
+ let isOptional: Bool
105
+
106
+ /// Has a default value. Not encoded (derivable as `!isOptional && !isRequired`, and a Swift default
107
+ /// never reaches JS); kept only to derive `isRequired`.
108
+ let hasDefault: Bool
109
+
110
+ /// Whether JS must supply this property, matching the macro's `RecordProperty.isRequired`. Encoded
111
+ /// as `required`.
112
+ var isRequired: Bool {
113
+ return !hasDefault && !isOptional
114
+ }
115
+
116
+ private enum CodingKeys: String, CodingKey {
117
+ case name, type
118
+ case isOptional = "optional"
119
+ case isRequired = "required"
120
+ }
121
+
122
+ func encode(to encoder: Encoder) throws {
123
+ var container = encoder.container(keyedBy: CodingKeys.self)
124
+ try container.encode(name, forKey: .name)
125
+ try container.encode(type, forKey: .type)
126
+ try container.encode(isOptional, forKey: .isOptional)
127
+ try container.encode(isRequired, forKey: .isRequired)
128
+ }
129
+ }
130
+
131
+ /// A `@ExpoModule` type and its `@JS` surface.
132
+ struct ExportedModule: Encodable, Equatable {
133
+ /// The Swift class name.
134
+ let name: String
135
+
136
+ /// The JS module name: `@ExpoModule("Foo")` override, else the class name.
137
+ let jsName: String
138
+
139
+ let functions: [ExportedFunction]
140
+ let properties: [ExportedProperty]
141
+
142
+ /// Absolute source path, matching `scan-modules`.
143
+ let file: String
144
+ }
145
+
146
+ /// A `@SharedObject` type: a JS class with an optional `@JS init` constructor plus its `@JS` members.
147
+ struct ExportedSharedObject: Encodable, Equatable {
148
+ /// The Swift class name.
149
+ let name: String
150
+
151
+ /// The JS class name: `@SharedObject("Foo")` override, else the class name.
152
+ let jsName: String
153
+
154
+ /// The `@JS init` parameters, or `nil` when there's none. A shared object has at most one.
155
+ let constructorParameters: [ExportedParameter]?
156
+
157
+ let functions: [ExportedFunction]
158
+ let properties: [ExportedProperty]
159
+
160
+ let file: String
161
+ }
162
+
163
+ /// A `@Record` type and its properties (data only: no functions, accessors, or constructor).
164
+ struct ExportedRecord: Encodable, Equatable {
165
+ /// The Swift type name (struct or class).
166
+ let name: String
167
+
168
+ let properties: [ExportedRecordProperty]
169
+
170
+ let file: String
171
+ }
172
+
173
+ /// The exported types grouped by kind, nested under `exports` in the result so the surface is one
174
+ /// self-contained object separate from `stats`.
175
+ struct ExportedSurface: Encodable, Equatable {
176
+ let modules: [ExportedModule]
177
+ let sharedObjects: [ExportedSharedObject]
178
+ let records: [ExportedRecord]
179
+ }
180
+
181
+ /// The `scan-exports` result: the surface plus the run's stats. A distinct envelope from
182
+ /// `ScanModulesResult` (different consumer: TS generation vs. autolinking).
183
+ struct ScanExportsResult: Encodable, Equatable {
184
+ let exports: ExportedSurface
185
+ let stats: ScanStats
186
+ }
@@ -0,0 +1,47 @@
1
+ import Foundation
2
+ import SwiftParser
3
+ import SwiftSyntax
4
+
5
+ extension Scanner {
6
+ /// Runs `scan-exports` over `paths`, prints the JSON report to stdout, and returns the exit code
7
+ /// (`0` on success, `1` if encoding fails). The deep counterpart to `runModules`.
8
+ public static func runExports(paths: [String]) -> Int32 {
9
+ let result = scanExports(paths: paths)
10
+
11
+ do {
12
+ let encoder = JSONEncoder()
13
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
14
+ let data = try encoder.encode(result)
15
+ FileHandle.standardOutput.write(data)
16
+ FileHandle.standardOutput.write(Data("\n".utf8))
17
+ return 0
18
+ } catch {
19
+ FileHandle.standardError.write(Data("error: failed to encode results: \(error)\n".utf8))
20
+ return 1
21
+ }
22
+ }
23
+ }
24
+
25
+ /// Scans `paths` for `@ExpoModule`, `@SharedObject`, and `@Record` types and returns their exported
26
+ /// surface plus the run's stats. Separate from the public entry so tests can drive it without
27
+ /// argv/stdout. The shared `scanFiles` walk + pre-filter selects files; a `SurfaceVisitor` extracts
28
+ /// each one.
29
+ func scanExports(paths: [String]) -> ScanExportsResult {
30
+ var modules: [ExportedModule] = []
31
+ var sharedObjects: [ExportedSharedObject] = []
32
+ var records: [ExportedRecord] = []
33
+
34
+ let stats = scanFiles(paths: paths, macros: [.expoModule, .sharedObject, .record]) { source, file in
35
+ let tree = Parser.parse(source: source)
36
+ let visitor = SurfaceVisitor(file: file)
37
+ visitor.walk(tree)
38
+ modules.append(contentsOf: visitor.modules)
39
+ sharedObjects.append(contentsOf: visitor.sharedObjects)
40
+ records.append(contentsOf: visitor.records)
41
+ }
42
+
43
+ return ScanExportsResult(
44
+ exports: ExportedSurface(modules: modules, sharedObjects: sharedObjects, records: records),
45
+ stats: stats
46
+ )
47
+ }