expo-modules-macros 0.35.0 → 0.36.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/Package.swift CHANGED
@@ -30,6 +30,8 @@ let package = Package(
30
30
  .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
31
31
  .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
32
32
  .product(name: "SwiftDiagnostics", package: "swift-syntax"),
33
+ // For the value of a string literal (`representedLiteralValue`).
34
+ .product(name: "SwiftParser", package: "swift-syntax"),
33
35
  // For the plugin server, which resolves the macros by type name (see PluginServer.swift).
34
36
  .product(name: "_SwiftCompilerPluginMessageHandling", package: "swift-syntax"),
35
37
  ],
@@ -47,6 +49,7 @@ let package = Package(
47
49
  .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
48
50
  .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
49
51
  .product(name: "SwiftDiagnostics", package: "swift-syntax"),
52
+ .product(name: "SwiftParser", package: "swift-syntax"),
50
53
  .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
51
54
  ],
52
55
  path: "apple/Sources/ExpoModulesMacros"
package/README.md CHANGED
@@ -25,7 +25,7 @@ This package lists them as optional dependencies, and their `os`, `cpu` and (on
25
25
  # Macros
26
26
 
27
27
  - **`@ExpoModule(_ name: String? = nil, classes: [Any.Type] = [])`** on a class. Turns the class into a module: binds its `@JS` members into the module's JavaScript object and resolves the module name from the argument, falling back to the class name. It also synthesizes everything inheriting from `Module` used to provide, so a module class can carry any superclass, or none. It adds an `@unchecked Sendable` conformance when the class isn't `Sendable` yet (see [Sendable conformance](#sendable-conformance)).
28
- - **`@JS(_ jsName: String? = nil, _ options: JSOptions...)`** on a member of a module or shared object. Marks a function, property or initializer for export. The class macro reads only the class body, so `@JS` on a member of an extension, or of a type that is neither a module nor a shared object, is an error. So is `@JS` on an initializer or a `static` member of a module, since JavaScript doesn't construct a module and gets only its instance. `@JS` on any other declaration, such as a subscript, is an error too. A `@JS` function or initializer can't be generic, since the binding converts each value by its static type. `@ExpoModule` and `@SharedObject` bind each marked member straight into the JavaScript object, with the argument decoding, the call and the result encoding inlined, so there is no dynamic per-call path. The JS name defaults to the Swift name; pass a string to override it. Two `@JS` members with the same JS name on one JavaScript object are a compile-time error, since JavaScript could reach only one of them. A shared object's static and instance members bind onto different objects, so they can share a name. A name that JavaScript already gives a meaning on the object is an error too: `__proto__` on any object, `constructor` on a shared object's prototype, and `prototype` on its constructor. The macro also checks that every type crossing the boundary is convertible in the direction it travels. For a top-level declaration, a bad type is reported on the author's own declaration. In an `@ExpoModule` or `@SharedObject` class, the class macro makes the check, since only it sees the type aliases in the class body, so the error is reported on the class and names the member and its argument labels, such as `local function 'move(_:by:returning:)' requires that 'Point' conform to 'JavaScriptEncodable'`. A `lazy var` is read-only from JavaScript. On the first read, its getter stores the value as a data property on the JavaScript object, so later reads don't call into native code. On a frozen object the value isn't stored, and each read calls the getter. The `.concurrent` option moves an `async` body off the JavaScript thread while still decoding and encoding on it. The `.withAsync("name")` option binds a synchronous function a second time under that name, as a function that returns a promise and runs the body on the concurrent thread pool. That function isn't isolated to the JavaScript thread, so its body must be safe to run on any thread. Because of that, a `.concurrent` function and a function with `.withAsync` can't take a JavaScript value such as `JavaScriptValue` or `JavaScriptObject`, which is valid only on the JavaScript thread; the macro reports such a parameter. The macro reports a function isolated to a global actor through its own attribute or its type's. When the isolation comes from elsewhere, such as a protocol, the error points into the generated code, so mark the function `nonisolated`. A function or initializer can take a closure argument, such as `(Point) throws -> Size` or `(String) async throws -> Data`. JavaScript passes a function for it, and native code can store the closure and call it from any thread. A closure that throws or returns a value blocks the caller until JavaScript returns, and an `async` closure suspends instead and awaits a promise that the function returns. A closure that returns a value must be `throws`, so that an error from JavaScript can reach the caller. A non-throwing `Void` closure returns before JavaScript reads its arguments, so don't change a class instance after passing it to one. A synchronous function or an initializer can take the JavaScript runtime as its last parameter, `in runtime: borrowing JavaScriptRuntime`, for example to create a JavaScript value. The binding passes the runtime of the call, and JavaScript doesn't see the parameter. The runtime can be used only on the JavaScript thread, and an `async` function can resume on another thread after an `await`, so the macro reports the parameter on an `async` function or a function with `.withAsync`, and when it isn't the last parameter, isn't labeled `in`, isn't `borrowing` or is optional. The macro finds the parameter by its type as written, so write it as `JavaScriptRuntime`: a typealias or a subclass is decoded as a JavaScript argument instead.
28
+ - **`@JS(_ jsName: String? = nil, _ options: JSOptions...)`** and **`@JS(symbol:_:)`** on a member of a module or shared object. Marks a function, property or initializer for export. The class macro reads only the class body, so `@JS` on a member of an extension, or of a type that is neither a module nor a shared object, is an error. So is `@JS` on an initializer or a `static` member of a module, since JavaScript doesn't construct a module and gets only its instance. `@JS` on any other declaration, such as a subscript, is an error too. A `@JS` function or initializer can't be generic, since the binding converts each value by its static type. `@ExpoModule` and `@SharedObject` bind each marked member straight into the JavaScript object, with the argument decoding, the call and the result encoding inlined, so there is no dynamic per-call path. The JS name defaults to the Swift name; pass a string to override it. Two `@JS` members with the same JS name on one JavaScript object are a compile-time error, since JavaScript could reach only one of them. A shared object's static and instance members bind onto different objects, so they can share a name. A name that JavaScript already gives a meaning on the object is an error too: `__proto__` on any object, `constructor` on a shared object's prototype, and `prototype` on its constructor. To bind a function or property under a symbol instead of a name, pass `symbol:`: a case of `JavaScriptSymbol.WellKnown`, such as `@JS(symbol: .iterator)` for `Symbol.iterator`, or a string literal, such as `@JS(symbol: "app.id")` for `Symbol.for("app.id")`. A function bound under a symbol is named after it in brackets, such as `[Symbol.iterator]`, as JavaScript names such a method. A function under `Symbol.hasInstance` or `Symbol.toStringTag` is defined as a data property first, since an assignment can't shadow what the prototype chain holds under these keys: a read-only `Symbol.hasInstance` on `Function.prototype`, and a `Symbol.toStringTag` accessor without a setter on the base `SharedObject` prototype. A symbol never collides with a name, and `symbol:` on an initializer is an error. In the `scan-exports` output, such a member has a `symbol` field instead of a `jsName`. The macro also checks that every type crossing the boundary is convertible in the direction it travels. For a top-level declaration, a bad type is reported on the author's own declaration. In an `@ExpoModule` or `@SharedObject` class, the class macro makes the check, since only it sees the type aliases in the class body, so the error is reported on the class and names the member and its argument labels, such as `local function 'move(_:by:returning:)' requires that 'Point' conform to 'JavaScriptEncodable'`. A `lazy var` is read-only from JavaScript. On the first read, its getter stores the value as a data property on the JavaScript object, so later reads don't call into native code. On a frozen object the value isn't stored, and each read calls the getter. The `.concurrent` option moves an `async` body off the JavaScript thread while still decoding and encoding on it. The `.withAsync("name")` option binds a synchronous function a second time under that name, as a function that returns a promise and runs the body on the concurrent thread pool. That function isn't isolated to the JavaScript thread, so its body must be safe to run on any thread. Because of that, a `.concurrent` function and a function with `.withAsync` can't take a JavaScript value such as `JavaScriptValue` or `JavaScriptObject`, which is valid only on the JavaScript thread; the macro reports such a parameter. The macro reports a function isolated to a global actor through its own attribute or its type's. When the isolation comes from elsewhere, such as a protocol, the error points into the generated code, so mark the function `nonisolated`. A function or initializer can take a closure argument, such as `(Point) throws -> Size` or `(String) async throws -> Data`. JavaScript passes a function for it, and native code can store the closure and call it from any thread. A closure that throws or returns a value blocks the caller until JavaScript returns, and an `async` closure suspends instead and awaits a promise that the function returns. A closure that returns a value must be `throws`, so that an error from JavaScript can reach the caller. A non-throwing `Void` closure returns before JavaScript reads its arguments, so don't change a class instance after passing it to one. A synchronous function or an initializer can take the JavaScript runtime as its last parameter, `in runtime: borrowing JavaScriptRuntime`, for example to create a JavaScript value. The binding passes the runtime of the call, and JavaScript doesn't see the parameter. The runtime can be used only on the JavaScript thread, and an `async` function can resume on another thread after an `await`, so the macro reports the parameter on an `async` function or a function with `.withAsync`, and when it isn't the last parameter, isn't labeled `in`, isn't `borrowing` or is optional. The macro finds the parameter by its type as written, so write it as `JavaScriptRuntime`: a typealias or a subclass is decoded as a JavaScript argument instead.
29
29
  - **`@Event(_ name: String? = nil, _ options: EventOptions...)`** on a function-typed `var`. Expands the property into a closure that emits the event, so calling the property sends it to JavaScript with the closure's parameter as the payload. The JS name defaults to the property name with a leading `on` stripped. `@ExpoModule` and `@SharedObject` warn when two events in the class body have the same JS name. By default the event is scheduled on the JavaScript thread. The `.inline` option runs the listeners before the call returns when it is made on the JavaScript thread, and schedules the event otherwise; it never blocks. The `.blocking` option runs the listeners before the call returns on any thread, so a call from another thread blocks until the JavaScript thread runs them. An event property is callable from any thread with each option.
30
30
  - **`@SharedObject(_ name: String? = nil)`** on a subclass of `SharedObject`, direct or not, such as a `SharedRef<T>` subclass. Binds the class's `@JS` members through the `SharedObject` hooks that core calls when it builds the JavaScript class, and emits the JS class name, which defaults to the class name. A class without an inheritance clause is an error, with a Fix-It that adds `: SharedObject`. A module exposes the class through `@ExpoModule(classes:)`, which only accepts `SharedObject` subclasses. A subclass of another `@SharedObject` class also gets the `@JS` members of that class, and its own members replace inherited members with the same JS name. A subclass that isn't `final` can't be constructed from JavaScript through the `@JS init` of its superclass, so give each subclass that JavaScript constructs its own `@JS init`. Like `@ExpoModule`, it adds an `@unchecked Sendable` conformance when the class isn't `Sendable` yet.
31
31
  - **`@Record()`** on a struct, or on a final class with no inheritance clause (declare a class's protocol conformances in an extension). Treats every non-static, non-private, non-computed stored property as a field, with no per-field wrapper, and synthesizes the memberwise initializer, the conversions in both directions, and the `Record` conformance. A record converts only from and to JavaScript values: its `from(dictionary:appContext:)` throws `RecordDictionaryConversionException`, so a record can't be created from a native `[String: Any]`. A `@Union` can be a record property. Requiredness is inferred: a default value makes a field optional, an optional type makes it nullable and optional. The memberwise initializer sets every field, so a field that is a `let` with a default value is an error. It doesn't set a `private` or `fileprivate` stored property, so such a property needs a default value, unless it's an optional `var` or the type declares its own memberwise initializer.
Binary file
@@ -72,8 +72,12 @@ struct ExportedFunction: Encodable, Equatable {
72
72
  let name: String
73
73
 
74
74
  /// The JS name it binds under: the `@JS("x")` override, else `name`. The second entry of a function
75
- /// with `.withAsync("x")` binds under `x`.
76
- let jsName: String
75
+ /// with `.withAsync("x")` binds under `x`. `nil` for a function bound under a symbol (see `symbol`).
76
+ let jsName: String?
77
+
78
+ /// The symbol it binds under with `@JS(symbol: ...)`, or `nil` when it binds under `jsName`.
79
+ /// Exactly one of `jsName` and `symbol` is set.
80
+ var symbol: ExportedSymbol? = nil
77
81
 
78
82
  var parameters: [ExportedParameter]
79
83
 
@@ -96,20 +100,41 @@ struct ExportedFunction: Encodable, Equatable {
96
100
 
97
101
  /// The flags are encoded under their TS-keyword spellings.
98
102
  private enum CodingKeys: String, CodingKey {
99
- case name, jsName, parameters, returns, accessLevel, platforms
103
+ case name, jsName, symbol, parameters, returns, accessLevel, platforms
100
104
  case isAsync = "async"
101
105
  case isThrowing = "throws"
102
106
  case isStatic = "static"
103
107
  }
104
108
  }
105
109
 
110
+ /// The symbol that a `@JS(symbol: ...)` member binds under.
111
+ struct ExportedSymbol: Encodable, Hashable {
112
+ enum Kind: String, Encodable, Hashable {
113
+ /// A well-known symbol, such as `Symbol.iterator`, from `@JS(symbol: .iterator)`.
114
+ case wellKnown
115
+ /// The symbol that the global registry holds for a key, `Symbol.for("app.id")`, from
116
+ /// `@JS(symbol: "app.id")`.
117
+ case registered
118
+ }
119
+
120
+ let kind: Kind
121
+
122
+ /// The name of the well-known symbol, such as `iterator`, or the registry key.
123
+ let name: String
124
+ }
125
+
106
126
  /// One `@JS var` on a module or shared object.
107
127
  struct ExportedProperty: Encodable, Equatable {
108
128
  /// The Swift declaration name.
109
129
  let name: String
110
130
 
111
- /// The JS name it binds under: the `@JS("x")` override, else `name`.
112
- let jsName: String
131
+ /// The JS name it binds under: the `@JS("x")` override, else `name`. `nil` for a property bound
132
+ /// under a symbol (see `symbol`).
133
+ let jsName: String?
134
+
135
+ /// The symbol it binds under with `@JS(symbol: ...)`, or `nil` when it binds under `jsName`.
136
+ /// Exactly one of `jsName` and `symbol` is set.
137
+ var symbol: ExportedSymbol? = nil
113
138
 
114
139
  /// The value type, or `nil` when undeterminable syntactically (no annotation and no literal
115
140
  /// default), the case the macro binds getter-only.
@@ -132,7 +157,7 @@ struct ExportedProperty: Encodable, Equatable {
132
157
  let platforms: [ExportedPlatform]
133
158
 
134
159
  private enum CodingKeys: String, CodingKey {
135
- case name, jsName, type, defaultValue, accessLevel, platforms
160
+ case name, jsName, symbol, type, defaultValue, accessLevel, platforms
136
161
  case isReadonly = "readonly"
137
162
  case isStatic = "static"
138
163
  }
@@ -140,7 +165,8 @@ struct ExportedProperty: Encodable, Equatable {
140
165
  func encode(to encoder: Encoder) throws {
141
166
  var container = encoder.container(keyedBy: CodingKeys.self)
142
167
  try container.encode(name, forKey: .name)
143
- try container.encode(jsName, forKey: .jsName)
168
+ try container.encodeIfPresent(jsName, forKey: .jsName)
169
+ try container.encodeIfPresent(symbol, forKey: .symbol)
144
170
  try container.encodeIfPresent(type, forKey: .type)
145
171
  try container.encode(!isSettable, forKey: .isReadonly)
146
172
  try container.encode(isStatic, forKey: .isStatic)
@@ -468,7 +494,8 @@ struct ExportedView: Encodable, Equatable {
468
494
  /// The Swift class name. Only a view declared at file scope is reported, as in `scan-modules`.
469
495
  let name: String
470
496
 
471
- /// The name the view registers under. `@ExpoView` takes no name, so it is always the class name.
497
+ /// The name the view registers under: the `@ExpoView<CardProps>("Foo")` override when present,
498
+ /// otherwise the class name.
472
499
  let jsName: String
473
500
 
474
501
  /// The props type: the generic argument of `@ExpoView<CardProps>`, as written without backticks, or
@@ -606,9 +633,10 @@ enum ExportedEventDispatch: String, Encodable, Equatable {
606
633
  /// `defaultValue`, and replaces `constructorParameters` with `initializer`; version 9 adds the
607
634
  /// `sharedRef` type kind and the `sharedRef` of a shared object; version 10 adds the `tuple` and `object` type kinds (named after a tuple alias)
608
635
  /// and resolves the tuple type aliases declared in `@ExpoModule` and `@SharedObject` classes; version 11
609
- /// replaces the `sync` flag of an event with `dispatch`. A new field that older consumers can ignore,
610
- /// such as `views`, doesn't change the version.
611
- let scanExportsSchemaVersion = 11
636
+ /// replaces the `sync` flag of an event with `dispatch`; version 12 adds the `symbol` of a function or
637
+ /// property bound with `@JS(symbol: ...)`, which then has no `jsName`. A new field that older consumers
638
+ /// can ignore, such as `views`, doesn't change the version.
639
+ let scanExportsSchemaVersion = 12
612
640
 
613
641
  /// The `scan-exports` result: the surface plus the run's stats. A distinct envelope from
614
642
  /// `ScanModulesResult` (different consumer: TS generation vs. autolinking).
@@ -20,7 +20,7 @@ extension ExportedSurface {
20
20
  /// `platforms`.
21
21
  ///
22
22
  /// The members merge the way the macro binds them: the inherited ones first, then the class's own. An
23
- /// own member replaces an inherited function or property with the same JS name, separately for static
23
+ /// own member replaces an inherited function or property with the same JS name or symbol, separately for static
24
24
  /// and instance members. Events are kept from both: an event is a stored property that emits under its
25
25
  /// JS name, so a subclass can't replace an inherited one, and both are sent. The `@JS init` isn't
26
26
  /// inherited: core doesn't construct a subclass through the initializer of its superclass.
@@ -52,17 +52,17 @@ extension ExportedSurface {
52
52
  var result = sharedObject
53
53
 
54
54
  let ownMembers = Set(
55
- sharedObject.functions.map { MemberKey(jsName: $0.jsName, isStatic: $0.isStatic) }
56
- + sharedObject.properties.map { MemberKey(jsName: $0.jsName, isStatic: $0.isStatic) }
55
+ sharedObject.functions.map { MemberKey(jsName: $0.jsName, symbol: $0.symbol, isStatic: $0.isStatic) }
56
+ + sharedObject.properties.map { MemberKey(jsName: $0.jsName, symbol: $0.symbol, isStatic: $0.isStatic) }
57
57
  )
58
58
  result.functions =
59
59
  parents.flatMap(\.functions).filter {
60
- !ownMembers.contains(MemberKey(jsName: $0.jsName, isStatic: $0.isStatic))
60
+ !ownMembers.contains(MemberKey(jsName: $0.jsName, symbol: $0.symbol, isStatic: $0.isStatic))
61
61
  }
62
62
  + sharedObject.functions
63
63
  result.properties =
64
64
  parents.flatMap(\.properties).filter {
65
- !ownMembers.contains(MemberKey(jsName: $0.jsName, isStatic: $0.isStatic))
65
+ !ownMembers.contains(MemberKey(jsName: $0.jsName, symbol: $0.symbol, isStatic: $0.isStatic))
66
66
  }
67
67
  + sharedObject.properties
68
68
  result.events = parents.flatMap(\.events) + sharedObject.events
@@ -91,7 +91,9 @@ extension ExportedSurface {
91
91
 
92
92
  /// A member's slot on the JavaScript side: static members live on the constructor and instance members
93
93
  /// on the prototype, so the two don't replace each other.
94
+ /// The key a member binds under: its JS name or its symbol, one of which is set.
94
95
  private struct MemberKey: Hashable {
95
- let jsName: String
96
+ let jsName: String?
97
+ let symbol: ExportedSymbol?
96
98
  let isStatic: Bool
97
99
  }
@@ -1,3 +1,4 @@
1
+ import SwiftParser
1
2
  import SwiftSyntax
2
3
 
3
4
  /// Extracts the full JS-exported surface of every `@ExpoModule`, `@SharedObject`, `@Record`,
@@ -222,7 +223,7 @@ final class SurfaceVisitor: SyntaxVisitor {
222
223
  views.append(
223
224
  ExportedView(
224
225
  name: qualified,
225
- jsName: name,
226
+ jsName: stringArgument(of: attribute) ?? name,
226
227
  propsType: String(propsType.trimmedDescription.filter { $0 != "`" }),
227
228
  propsTypeLookupName: spellingWithoutGenericArguments(of: propsType),
228
229
  accessLevel: accessLevel,
@@ -366,10 +367,11 @@ final class SurfaceVisitor: SyntaxVisitor {
366
367
  let isAsync = effects?.asyncSpecifier != nil
367
368
  let exportedParameters = parameters(of: funcDecl.signature.parameterClause)
368
369
  let returns = isVoidType(returnType) ? nil : returnType.map { typeNode(from: $0) }
369
- let makeFunction = { (jsName: String, isAsync: Bool) in
370
+ let makeFunction = { (jsName: String?, symbol: ExportedSymbol?, isAsync: Bool) in
370
371
  ExportedFunction(
371
372
  name: funcDecl.name.identifierName,
372
373
  jsName: jsName,
374
+ symbol: symbol,
373
375
  parameters: exportedParameters,
374
376
  returns: returns,
375
377
  isAsync: isAsync,
@@ -379,12 +381,15 @@ final class SurfaceVisitor: SyntaxVisitor {
379
381
  platforms: platforms
380
382
  )
381
383
  }
382
- let function = makeFunction(stringArgument(of: attribute) ?? funcDecl.name.identifierName, isAsync)
383
- // The macro binds no variant for an `async` function: it reports the option there instead.
384
+ let symbol = symbolArgument(of: attribute)
385
+ let jsName = symbol == nil ? stringArgument(of: attribute) ?? funcDecl.name.identifierName : nil
386
+ let function = makeFunction(jsName, symbol, isAsync)
387
+ // The macro binds no variant for an `async` function: it reports the option there instead. The
388
+ // variant binds under its own name, also for a function bound under a symbol.
384
389
  guard !isAsync, let variantName = asyncVariantName(of: attribute) else {
385
390
  return [function]
386
391
  }
387
- return [function, makeFunction(variantName, true)]
392
+ return [function, makeFunction(variantName, nil, true)]
388
393
  }
389
394
 
390
395
  /// Builds the `ExportedProperty` entries for a `@JS var`/`let`. One declaration can introduce several
@@ -399,6 +404,7 @@ final class SurfaceVisitor: SyntaxVisitor {
399
404
  // A `lazy var` is read-only from JS, like a `let`: the macro binds it getter-only.
400
405
  let isLazy = varDecl.modifiers.contains { $0.name.tokenKind == .keyword(.lazy) }
401
406
  let isStatic = isTypeLevel(varDecl.modifiers)
407
+ let symbol = symbolArgument(of: attribute)
402
408
  let override = stringArgument(of: attribute)
403
409
  var result: [ExportedProperty] = []
404
410
 
@@ -411,7 +417,8 @@ final class SurfaceVisitor: SyntaxVisitor {
411
417
  result.append(
412
418
  ExportedProperty(
413
419
  name: name,
414
- jsName: override ?? name,
420
+ jsName: symbol == nil ? override ?? name : nil,
421
+ symbol: symbol,
415
422
  type: type,
416
423
  isSettable: !isLazy && isSettable(binding: binding, isLet: isLet),
417
424
  isStatic: isStatic,
@@ -895,8 +902,8 @@ private func hasEventOption(_ attribute: AttributeSyntax, named option: String)
895
902
 
896
903
  /// The name in a `@JS` attribute's `.withAsync("name")` option, in the arguments or inside an array
897
904
  /// literal (`@JS("load", [.withAsync("loadAsync")])`), or `nil` when the option is absent or its
898
- /// argument isn't a plain string literal. Mirrors `asyncVariantOption` and `asyncVariantName` in the
899
- /// macros.
905
+ /// argument isn't a string literal without interpolation. The name is the value of the literal.
906
+ /// Mirrors `asyncVariantOption` and `asyncVariantName` in the macros.
900
907
  private func asyncVariantName(of attribute: AttributeSyntax) -> String? {
901
908
  guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else {
902
909
  return nil
@@ -916,14 +923,11 @@ private func asyncVariantName(of attribute: AttributeSyntax) -> String? {
916
923
  }
917
924
  guard call.arguments.count == 1,
918
925
  let argument = call.arguments.first,
919
- argument.label == nil,
920
- let literal = argument.expression.as(StringLiteralExprSyntax.self),
921
- literal.segments.count == 1,
922
- let segment = literal.segments.first?.as(StringSegmentSyntax.self)
926
+ argument.label == nil
923
927
  else {
924
928
  return nil
925
929
  }
926
- return segment.content.text
930
+ return argument.expression.as(StringLiteralExprSyntax.self)?.representedLiteralValue
927
931
  }
928
932
  return nil
929
933
  }
@@ -44,9 +44,10 @@ struct ScannedClass: Codable, Equatable {
44
44
  /// The Swift class name.
45
45
  let name: String
46
46
 
47
- /// The fully-resolved JS name: the `@ExpoModule("Foo")` or `@SharedObject("Foo")` override when
48
- /// present, otherwise the class name. `@ExpoView` takes no name, so a view's JS name is its class name. Resolved here (rather than left `nil`) so it matches how the
49
- /// macro derives the name and the consumer never has to apply the fallback itself.
47
+ /// The fully-resolved JS name: the `@ExpoModule("Foo")`, `@SharedObject("Foo")` or
48
+ /// `@ExpoView<CardProps>("Foo")` override when present, otherwise the class name. Resolved here
49
+ /// (rather than left `nil`) so it matches how the macro derives the name and the consumer never has
50
+ /// to apply the fallback itself.
50
51
  let jsName: String
51
52
 
52
53
  /// The class's spelled access modifier (`open`, `public`, `package`, `fileprivate`, `private`),
@@ -155,8 +156,8 @@ func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResul
155
156
 
156
157
  // The macros reject anything but a class, so a struct with one of them is never registered.
157
158
  for detection in allModules.detections where detection.declarationKind == "class" {
158
- // Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` or
159
- // `@SharedObject("Foo")` override, else the class name.
159
+ // Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")`,
160
+ // `@SharedObject("Foo")` or `@ExpoView<CardProps>("Foo")` override, else the class name.
160
161
  let scanned = ScannedClass(
161
162
  name: detection.name,
162
163
  jsName: detection.jsName ?? detection.name,
@@ -1,5 +1,6 @@
1
1
  import SwiftDiagnostics
2
2
  import SwiftIfConfig
3
+ import SwiftParser
3
4
  import SwiftSyntax
4
5
 
5
6
  /// Walks a parsed source file and records top-level declarations carrying `@ExpoModule`, `@JS`,
@@ -206,19 +207,49 @@ private func arguments(of attribute: AttributeSyntax) -> [MacroArgument] {
206
207
  }
207
208
  }
208
209
 
209
- /// The first string-literal argument of an attribute, e.g. `@JS("doWork")` -> "doWork". Returns
210
- /// `nil` when there's no argument or it isn't a plain string literal. (Same shape the
211
- /// `jsNameArgument` helper reads inside the macros.) Shared with the `scan-exports` surface visitor,
212
- /// which reads the same JS-name override off `@JS`/`@ExpoModule`/`@SharedObject`.
210
+ /// The spellings of the type of the well-known symbols that a `symbol:` argument can name before the
211
+ /// case, as in the macros.
212
+ private let wellKnownSymbolTypeNames: Set<String> = [
213
+ "JavaScriptSymbol.WellKnown",
214
+ "ExpoModulesJSI.JavaScriptSymbol.WellKnown",
215
+ "ExpoModulesCore.JavaScriptSymbol.WellKnown",
216
+ ]
217
+
218
+ /// The `symbol:` argument of `@JS`, the same as the macros read it: a case of
219
+ /// `JavaScriptSymbol.WellKnown` names a well-known symbol (`@JS(symbol: .iterator)`), and a string
220
+ /// literal without interpolation a registry key (`@JS(symbol: "app.id")`), with its escape sequences
221
+ /// decoded. `nil` when the attribute has none, or when the argument has another shape, such as a
222
+ /// constant `Keys.id`, which `@JS` reports.
223
+ func symbolArgument(of attribute: AttributeSyntax) -> ExportedSymbol? {
224
+ guard let args = attribute.arguments?.as(LabeledExprListSyntax.self),
225
+ let argument = args.first(where: { $0.label?.text == "symbol" })
226
+ else {
227
+ return nil
228
+ }
229
+ if let member = argument.expression.as(MemberAccessExprSyntax.self) {
230
+ if let base = member.base, !wellKnownSymbolTypeNames.contains(base.trimmedDescription) {
231
+ return nil
232
+ }
233
+ return ExportedSymbol(kind: .wellKnown, name: member.declName.baseName.identifierName)
234
+ }
235
+ if let value = argument.expression.as(StringLiteralExprSyntax.self)?.representedLiteralValue {
236
+ return ExportedSymbol(kind: .registered, name: value)
237
+ }
238
+ return nil
239
+ }
240
+
241
+ /// The value of the first string-literal argument of an attribute, e.g. `@JS("doWork")` -> "doWork",
242
+ /// with its escape sequences decoded, so a raw string and an escape read as JavaScript sees the name.
243
+ /// Returns `nil` when there's no argument or it isn't an unlabeled string literal without
244
+ /// interpolation. (Same shape the `jsNameArgument` helper reads inside the macros.) Shared with the
245
+ /// `scan-exports` surface visitor, which reads the same JS-name override off
246
+ /// `@JS`/`@ExpoModule`/`@SharedObject`.
213
247
  func stringArgument(of attribute: AttributeSyntax) -> String? {
214
248
  guard let args = attribute.arguments?.as(LabeledExprListSyntax.self),
215
249
  let first = args.first,
216
- first.label == nil,
217
- let str = first.expression.as(StringLiteralExprSyntax.self),
218
- let segment = str.segments.first?.as(StringSegmentSyntax.self),
219
- str.segments.count == 1
250
+ first.label == nil
220
251
  else {
221
252
  return nil
222
253
  }
223
- return segment.content.text
254
+ return first.expression.as(StringLiteralExprSyntax.self)?.representedLiteralValue
224
255
  }
@@ -26,6 +26,11 @@ import SwiftSyntaxMacros
26
26
  /// public typealias Props = CardProps
27
27
  /// extension CardView: AnyNativeView {}
28
28
  ///
29
+ /// The view registers under its class name. Pass `@ExpoView<CardProps>("FancyCard")` to override it,
30
+ /// and the expansion also gets a `_jsName` static that core reads in place of the class name:
31
+ ///
32
+ /// public nonisolated static let _jsName = "FancyCard"
33
+ ///
29
34
  /// There is **no view definition**: no `ViewDefinition`, no `View(…) { }` builder, no `Props(_:)` or
30
35
  /// `Events(_:)` element. Core reaches the view through the `AnyNativeView` conformance: it reads the
31
36
  /// props type from the `Props` typealias, which satisfies the protocol's associated type, and the
@@ -83,11 +88,18 @@ public struct ExpoViewMacro: MemberMacro, ExtensionMacro {
83
88
  )
84
89
  }
85
90
 
86
- return [
91
+ var emitted: [DeclSyntax] = [
87
92
  """
88
93
  public typealias Props = \(raw: propsType)
89
94
  """
90
95
  ]
96
+
97
+ // `AnyNativeView` defaults `_jsName` to the class name, so it's emitted only for an override.
98
+ // `nonisolated`, because the protocol is `@MainActor` and core reads the name off the main actor.
99
+ if let jsName = jsNameArgument(of: node) {
100
+ emitted.append("public nonisolated static let _jsName = \"\(raw: jsName)\"")
101
+ }
102
+ return emitted
91
103
  }
92
104
 
93
105
  public static func expansion(
@@ -25,6 +25,12 @@ import SwiftSyntaxMacros
25
25
  ///
26
26
  /// @JS
27
27
  /// var status: String { "ok" }
28
+ ///
29
+ /// @JS(symbol: .iterator)
30
+ /// func makeIterator() -> JavaScriptObject { ... }
31
+ ///
32
+ /// @JS(symbol: "app.id")
33
+ /// var id: String { ... }
28
34
  public struct JSMacro: PeerMacro {
29
35
  public static func expansion(
30
36
  of node: AttributeSyntax,
@@ -52,6 +58,7 @@ public struct JSMacro: PeerMacro {
52
58
  diagnoseAsyncVariantOption(of: node, on: declaration, in: context)
53
59
  diagnoseJavaScriptValuesOffThread(of: node, on: declaration, in: context)
54
60
  diagnoseRuntimeParameter(of: node, on: declaration, in: context)
61
+ diagnoseSymbolArgument(of: node, on: declaration, in: context)
55
62
 
56
63
  // In an `@ExpoModule` or `@SharedObject` class, the class macro emits the assertion instead: it sees
57
64
  // the type aliases declared in the class body, which this macro can't (see `TupleTypeAliases`).
@@ -200,6 +207,44 @@ private func removeAttributeFixIts(_ node: AttributeSyntax, before keyword: Toke
200
207
  ]
201
208
  }
202
209
 
210
+ /// Emits the diagnostics for the `symbol:` argument of `@JS`, which binds the member under a symbol
211
+ /// (see `JSMemberKey`). The argument must be a case of `JavaScriptSymbol.WellKnown` or a string literal
212
+ /// that the macro can read, and an initializer can't take it, since a constructor has no key.
213
+ private func diagnoseSymbolArgument(
214
+ of node: AttributeSyntax,
215
+ on declaration: some DeclSyntaxProtocol,
216
+ in context: some MacroExpansionContext
217
+ ) {
218
+ guard let argument = symbolArgument(of: node) else {
219
+ return
220
+ }
221
+ if declaration.is(InitializerDeclSyntax.self) {
222
+ context.diagnose(
223
+ Diagnostic(
224
+ node: argument,
225
+ message: JSDiagnosticMessage(
226
+ "'symbol:' applies only to a @JS function or property: JavaScript constructs a shared object with 'new', so an initializer isn't bound under a key.",
227
+ id: "js-symbol-on-initializer",
228
+ severity: .error
229
+ )
230
+ )
231
+ )
232
+ return
233
+ }
234
+ if symbolKey(of: node) == nil {
235
+ context.diagnose(
236
+ Diagnostic(
237
+ node: argument.expression,
238
+ message: JSDiagnosticMessage(
239
+ "'symbol:' takes a well-known symbol, such as '.iterator', or the registry key of 'Symbol.for' as a string literal, such as \"app.id\". The macro reads the argument from the source, so it can't be a variable, a constant or an interpolated string.",
240
+ id: "js-symbol-argument",
241
+ severity: .error
242
+ )
243
+ )
244
+ )
245
+ }
246
+ }
247
+
203
248
  /// Emits the diagnostic for `@JS(.concurrent)` on a member that can't take it. The option maps to
204
249
  /// Swift's `@concurrent`, which requires an `async` function: a synchronous member has nowhere to
205
250
  /// suspend, and a property or initializer can't be async at all. Diagnosing here points at the
@@ -21,9 +21,13 @@ import SwiftSyntax
21
21
  /// A synchronous function can take the JavaScript runtime as its last parameter,
22
22
  /// `in runtime: borrowing JavaScriptRuntime`. JavaScript doesn't see it: the binding passes the runtime
23
23
  /// of the call.
24
+ ///
25
+ /// A function marked `@JS(symbol: ...)` binds under a symbol instead of a string name (see
26
+ /// `JSMemberKey`).
24
27
  internal struct JSFunction {
25
28
  let swiftName: String
26
- private(set) var jsName: String
29
+ /// The key that the function binds under.
30
+ private(set) var key: JSMemberKey
27
31
  /// The parameters that take JavaScript arguments, without the runtime parameter.
28
32
  let parameters: [FunctionParameterSyntax]
29
33
  /// The label of the parameter that takes the JavaScript runtime, or `nil` when the function has none.
@@ -44,7 +48,7 @@ internal struct JSFunction {
44
48
 
45
49
  init(funcDecl: FunctionDeclSyntax, attribute: AttributeSyntax) {
46
50
  self.swiftName = funcDecl.name.text
47
- self.jsName = jsNameArgument(of: attribute) ?? withoutBackticks(funcDecl.name.text)
51
+ self.key = JSMemberKey(attribute: attribute, defaultName: withoutBackticks(funcDecl.name.text))
48
52
  self.nameToken = funcDecl.name
49
53
  let declaredParameters = funcDecl.signature.parameterClause.parameters
50
54
  self.parameters = declaredParameters.filter { !isRuntimeParameter($0) }
@@ -68,7 +72,7 @@ internal struct JSFunction {
68
72
  return nil
69
73
  }
70
74
  var variant = self
71
- variant.jsName = asyncVariantName.name
75
+ variant.key = .name(asyncVariantName.name)
72
76
  variant.nameToken = asyncVariantName.token
73
77
  variant.isAsync = true
74
78
  variant.isAsyncVariant = true
@@ -112,7 +116,7 @@ internal struct JSFunction {
112
116
  lines.append(
113
117
  """
114
118
  guard arguments.count == \(maximum) else {
115
- throw Exceptions.ArgumentsRangeMismatch((functionName: "\(jsName)", received: arguments.count, required: \(required), maximum: \(maximum)))
119
+ throw Exceptions.ArgumentsRangeMismatch((functionName: \(key.functionNameLiteral), received: arguments.count, required: \(required), maximum: \(maximum)))
116
120
  }
117
121
  """
118
122
  )
@@ -120,7 +124,7 @@ internal struct JSFunction {
120
124
  lines.append(
121
125
  """
122
126
  guard arguments.count >= \(required) && arguments.count <= \(maximum) else {
123
- throw Exceptions.ArgumentsRangeMismatch((functionName: "\(jsName)", received: arguments.count, required: \(required), maximum: \(maximum)))
127
+ throw Exceptions.ArgumentsRangeMismatch((functionName: \(key.functionNameLiteral), received: arguments.count, required: \(required), maximum: \(maximum)))
124
128
  }
125
129
  """
126
130
  )
@@ -337,6 +341,21 @@ internal struct JSFunction {
337
341
  /// the host-function closure is what keeps the native callable alive for as long as JS can invoke
338
342
  /// it; its lifetime is bounded by the JS VM's garbage collection of the object. A shared object
339
343
  /// captures nothing of the instance: it recovers the typed receiver from the JS `this` per call.
344
+ ///
345
+ /// A function bound under a symbol gets the symbol from the runtime first, so the statement is
346
+ /// marked `try`.
347
+ /// Whether the body of a synchronous binding uses the runtime: to decode an argument, to unwrap a shared
348
+ /// object from `this`, or to pass it to a function that takes the runtime.
349
+ private func bodyUsesRuntime(receiver: Receiver) -> Bool {
350
+ if runtimeLabel != nil || !parameters.isEmpty {
351
+ return true
352
+ }
353
+ if case .sharedObject = receiver {
354
+ return true
355
+ }
356
+ return false
357
+ }
358
+
340
359
  func decorateStatements(object: String, receiver: Receiver) -> String {
341
360
  // Every `@JS` binding hands `this` in as a borrowed `JavaScriptUnownedValue` instead of
342
361
  // allocating an owning `JavaScriptValue` and forming its `weak`-runtime reference on every call,
@@ -350,19 +369,21 @@ internal struct JSFunction {
350
369
  // returns nothing binds through the `Void` overload (`JavaScriptRuntime.VoidFunctionClosure`)
351
370
  // instead, which leaves JS's result as `undefined`. A call through it is faster than through a
352
371
  // result-slot closure that writes nothing. The closure declares `-> Void` because a body of several
353
- // statements without a `return` is otherwise ambiguous between the overloads. A function that takes
354
- // the runtime binds through the result-slot overload even when it returns nothing, so it gets the
355
- // runtime as the closure's parameter instead of capturing the decorator's.
372
+ // statements without a `return` is otherwise ambiguous between the overloads. The `Void` overload
373
+ // doesn't get the runtime, so it's only for a body that doesn't use it: a function that decodes
374
+ // arguments, unwraps a shared object or takes the runtime binds through the result-slot overload even
375
+ // when it returns nothing. Capturing the decorator's runtime instead would make the runtime own a
376
+ // closure that retains it, so a runtime that owns its engine would never be freed.
356
377
  let captures = receiver.captureClause
357
378
  let parameters =
358
379
  isAsync
359
380
  ? "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
360
- : returnType == nil && runtimeLabel == nil
381
+ : returnType == nil && !bodyUsesRuntime(receiver: receiver)
361
382
  ? "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) -> Void"
362
383
  : resultSlotClosureParameters
363
384
 
364
385
  return """
365
- \(object).setProperty("\(jsName)") { \(captures)\(parameters) in
386
+ \(key.tryPrefix)\(object).setProperty(\(key.expression)) { \(captures)\(parameters) in
366
387
  \(bodyStatements(receiver: receiver, indent: " "))
367
388
  }
368
389
  """
@@ -389,9 +410,13 @@ internal struct JSFunction {
389
410
  /// so each shared object instance stores its own value. `defineProperty` leaves a frozen object
390
411
  /// unchanged, so there the property stays an accessor. A `this` that isn't an object doesn't fail the read
391
412
  /// either: the getter still returns the value.
413
+ ///
414
+ /// A property marked `@JS(symbol: ...)` binds under a symbol instead of a string name (see
415
+ /// `JSMemberKey`).
392
416
  internal struct JSProperty {
393
417
  let swiftName: String
394
- let jsName: String
418
+ /// The key that the property binds under.
419
+ let key: JSMemberKey
395
420
  /// The property's value type as written, or `nil` when it couldn't be inferred (no annotation and
396
421
  /// no literal default). When `nil` the getter still works (the encode infers from `self.<name>`)
397
422
  /// but the setter uses an untyped closure parameter.
@@ -451,7 +476,8 @@ internal struct JSProperty {
451
476
  getBody = "result.emplace(\(encode))"
452
477
  }
453
478
  } else {
454
- let define = "this.getObject(in: runtime).defineProperty(\"\(jsName)\", value: value, options: [.enumerable])"
479
+ let define =
480
+ "\(key.tryPrefix)this.getObject(in: runtime).defineProperty(\(key.expression), value: value, options: [.enumerable])"
455
481
  // A module getter ignores `this` otherwise, so it can be anything, and only an object can hold the
456
482
  // value. A shared object's unwrap has already checked that `this` is an object.
457
483
  let defineStatement =
@@ -476,7 +502,7 @@ internal struct JSProperty {
476
502
  )
477
503
  }
478
504
 
479
- lines.append("\(object).defineProperty(\"\(jsName)\", descriptor: \(descriptorName))")
505
+ lines.append("\(key.tryPrefix)\(object).defineProperty(\(key.expression), descriptor: \(descriptorName))")
480
506
 
481
507
  return
482
508
  lines
@@ -543,7 +569,10 @@ private func encodeIntoSlotStatement(_ type: String, freeForm: FreeFormConversio
543
569
  ///
544
570
  /// `configurableProperties` makes the property accessors configurable. `replacesInherited` first
545
571
  /// redefines the name of each function as a writable data property: the object may already hold an
546
- /// inherited accessor with that name, which an assignment would call instead of replacing.
572
+ /// inherited accessor with that name, which an assignment would call instead of replacing. A function
573
+ /// bound under a symbol that an object up the prototype chain holds as a read-only property or as an
574
+ /// accessor without a setter is redefined first too, since an assignment can't shadow such a property
575
+ /// (see `JSMemberKey.isInheritedReadOnly`).
547
576
  private func decorateBody(
548
577
  functions: [JSFunction],
549
578
  properties: [JSProperty],
@@ -554,11 +583,11 @@ private func decorateBody(
554
583
  ) -> String {
555
584
  let functionBody = functions.map { function in
556
585
  let binding = function.decorateStatements(object: object, receiver: receiver)
557
- guard replacesInherited else {
586
+ guard replacesInherited || function.key.isInheritedReadOnly else {
558
587
  return binding
559
588
  }
560
589
  let redefine =
561
- " \(object).defineProperty(\"\(function.jsName)\", descriptor: .init(configurable: true, enumerable: true, writable: true))"
590
+ " \(function.key.tryPrefix)\(object).defineProperty(\(function.key.expression), descriptor: .init(configurable: true, enumerable: true, writable: true))"
562
591
  return redefine + "\n" + binding
563
592
  }
564
593
  let propertyBody = properties.map {
@@ -0,0 +1,125 @@
1
+ import SwiftParser
2
+ import SwiftSyntax
3
+
4
+ /// The JavaScript property key that a `@JS` function or property binds under: a string name, from
5
+ /// `@JS("name")` or the Swift name, or a symbol, from `@JS(symbol: ...)`.
6
+ ///
7
+ /// - `@JS(symbol: .iterator)` binds under a well-known symbol, `Symbol.iterator`. The case name is
8
+ /// a case of `JavaScriptSymbol.WellKnown`, which the macro declaration type-checks.
9
+ /// - `@JS(symbol: "app.id")` binds under the symbol that the global registry holds for the key,
10
+ /// `Symbol.for("app.id")`.
11
+ internal enum JSMemberKey: Hashable {
12
+ /// The name in the form of `literalContent(of:)`, which the generated code puts between quotes.
13
+ case name(String)
14
+ /// The name of a case of `JavaScriptSymbol.WellKnown`, such as `iterator`.
15
+ case wellKnownSymbol(String)
16
+ /// The registry key, the value of the string literal with its escape sequences decoded.
17
+ case registeredSymbol(String)
18
+
19
+ /// The key of a `@JS` member: the `symbol:` argument of the attribute when it has one, else the
20
+ /// string name of the attribute, else `defaultName`.
21
+ init(attribute: AttributeSyntax, defaultName: String) {
22
+ self = symbolKey(of: attribute) ?? .name(jsNameArgument(of: attribute) ?? defaultName)
23
+ }
24
+
25
+ var isSymbol: Bool {
26
+ if case .name = self {
27
+ return false
28
+ }
29
+ return true
30
+ }
31
+
32
+ /// True for a well-known symbol that an object up the prototype chain of a binding target holds in a
33
+ /// way that an assignment can't shadow:
34
+ /// - `Symbol.hasInstance`, a read-only property of `Function.prototype`, which a shared object
35
+ /// constructor inherits;
36
+ /// - `Symbol.toStringTag`, an accessor without a setter on the prototype of the base `SharedObject`
37
+ /// class, which core defines.
38
+ ///
39
+ /// A function under such a key is defined with `defineProperty` before it is bound.
40
+ var isInheritedReadOnly: Bool {
41
+ guard case .wellKnownSymbol(let name) = self else {
42
+ return false
43
+ }
44
+ return name == "hasInstance" || name == "toStringTag"
45
+ }
46
+
47
+ /// How diagnostics name the key, as JavaScript code would write it: `name`, `Symbol.iterator` or
48
+ /// `Symbol.for("app.id")`.
49
+ var description: String {
50
+ switch self {
51
+ case .name(let name):
52
+ return name
53
+ case .wellKnownSymbol(let name):
54
+ return "Symbol.\(name)"
55
+ case .registeredSymbol(let key):
56
+ return "Symbol.for(\(swiftStringLiteral(key)))"
57
+ }
58
+ }
59
+
60
+ /// The string literal of the `name` of a function bound under the key, as JavaScript gives it to a
61
+ /// method: the string name, or the description of the symbol in brackets, such as
62
+ /// `[Symbol.iterator]` or `[app.id]`. It names the function in an argument count error.
63
+ var functionNameLiteral: String {
64
+ switch self {
65
+ case .name(let name):
66
+ return "\"\(name)\""
67
+ case .wellKnownSymbol(let name):
68
+ return "\"[Symbol.\(name)]\""
69
+ case .registeredSymbol(let key):
70
+ return swiftStringLiteral("[\(key)]")
71
+ }
72
+ }
73
+
74
+ /// The expression that the generated code passes as the key: a string literal, or a throwing call
75
+ /// that gets the symbol from the runtime in scope (see `tryPrefix`). A well-known symbol is cached
76
+ /// in the runtime after its first use.
77
+ var expression: String {
78
+ switch self {
79
+ case .name(let name):
80
+ return "\"\(name)\""
81
+ case .wellKnownSymbol(let name):
82
+ return "JavaScriptSymbol.wellKnown(runtime, .\(name))"
83
+ case .registeredSymbol(let key):
84
+ return "JavaScriptSymbol.for(runtime, \(swiftStringLiteral(key)))"
85
+ }
86
+ }
87
+
88
+ /// The `try ` that a statement passing `expression` needs, or an empty string for a string key.
89
+ var tryPrefix: String {
90
+ return isSymbol ? "try " : ""
91
+ }
92
+ }
93
+
94
+ /// The spellings of the type of the well-known symbols that a `symbol:` argument can name before the
95
+ /// case. Any other base, such as a constant `Keys.iterator` of type `String`, isn't a well-known
96
+ /// symbol.
97
+ private let wellKnownSymbolTypeNames: Set<String> = [
98
+ "JavaScriptSymbol.WellKnown",
99
+ "ExpoModulesJSI.JavaScriptSymbol.WellKnown",
100
+ "ExpoModulesCore.JavaScriptSymbol.WellKnown",
101
+ ]
102
+
103
+ /// The key of a `symbol:` argument, or `nil` when the attribute has none or the argument is neither
104
+ /// a case of `JavaScriptSymbol.WellKnown` (`.iterator`, `JavaScriptSymbol.WellKnown.iterator`) nor a
105
+ /// string literal without interpolation, which `@JS` reports.
106
+ internal func symbolKey(of attribute: AttributeSyntax) -> JSMemberKey? {
107
+ guard let argument = symbolArgument(of: attribute) else {
108
+ return nil
109
+ }
110
+ if let member = argument.expression.as(MemberAccessExprSyntax.self) {
111
+ if let base = member.base, !wellKnownSymbolTypeNames.contains(base.trimmedDescription) {
112
+ return nil
113
+ }
114
+ return .wellKnownSymbol(withoutBackticks(member.declName.baseName.text))
115
+ }
116
+ if let value = argument.expression.as(StringLiteralExprSyntax.self)?.representedLiteralValue {
117
+ return .registeredSymbol(value)
118
+ }
119
+ return nil
120
+ }
121
+
122
+ /// The `symbol:` argument of an attribute, or `nil` when it has none.
123
+ internal func symbolArgument(of attribute: AttributeSyntax) -> LabeledExprSyntax? {
124
+ return attribute.arguments?.as(LabeledExprListSyntax.self)?.first { $0.label?.text == "symbol" }
125
+ }
@@ -2,8 +2,8 @@ import SwiftDiagnostics
2
2
  import SwiftSyntax
3
3
  import SwiftSyntaxMacros
4
4
 
5
- /// Reports each `@JS` member whose JavaScript name an earlier `@JS` member already binds on the same
6
- /// JavaScript object. Pass the members of one object only: a shared object's static members bind onto
5
+ /// Reports each `@JS` member whose JavaScript name or symbol an earlier `@JS` member already binds on the
6
+ /// same JavaScript object. A symbol never collides with a string name. Pass the members of one object only: a shared object's static members bind onto
7
7
  /// the constructor and its instance members onto the prototype, so the two sets are checked separately.
8
8
  internal func diagnoseJSNameCollisions(
9
9
  functions: [JSFunction],
@@ -14,11 +14,11 @@ internal func diagnoseJSNameCollisions(
14
14
  // name, JavaScript would reach only one. Members are compared in source order, so the error lands on
15
15
  // every member after the first, whichever kind each one is.
16
16
  let members = functions.map(BoundMember.init(function:)) + properties.map(BoundMember.init(property:))
17
- var firstMembers: [String: BoundMember] = [:]
17
+ var firstMembers: [JSMemberKey: BoundMember] = [:]
18
18
 
19
19
  for member in members.sorted(by: { $0.nameToken.position < $1.nameToken.position }) {
20
- guard let first = firstMembers[member.jsName] else {
21
- firstMembers[member.jsName] = member
20
+ guard let first = firstMembers[member.key] else {
21
+ firstMembers[member.key] = member
22
22
  continue
23
23
  }
24
24
  let renaming =
@@ -27,12 +27,12 @@ internal func diagnoseJSNameCollisions(
27
27
  Diagnostic(
28
28
  node: member.nameToken,
29
29
  message: JSNameCollisionMessage(
30
- "The JavaScript name '\(member.jsName)' is already bound by an earlier \(first.kind). A JavaScript object holds one member per name, so JavaScript could reach only one of them. Give one of them another name with \(renaming)."
30
+ "The JavaScript \(member.keyKind) '\(member.key.description)' is already bound by an earlier \(first.kind). A JavaScript object holds one member per \(member.keyKind), so JavaScript could reach only one of them. Give one of them another \(member.keyKind) with \(renaming)."
31
31
  ),
32
32
  notes: [
33
33
  Note(
34
34
  node: Syntax(first.nameToken),
35
- message: JSNameCollisionNote("'\(member.jsName)' is first bound here")
35
+ message: JSNameCollisionNote("'\(member.key.description)' is first bound here")
36
36
  )
37
37
  ]
38
38
  )
@@ -80,14 +80,15 @@ internal func diagnoseReservedJSNames(
80
80
  ) {
81
81
  let members = functions.map(BoundMember.init(function:)) + properties.map(BoundMember.init(property:))
82
82
  for member in members {
83
- guard let reason = target.reservedNames[member.jsName] else {
83
+ // A symbol key never means anything else on the object.
84
+ guard case .name(let jsName) = member.key, let reason = target.reservedNames[jsName] else {
84
85
  continue
85
86
  }
86
87
  context.diagnose(
87
88
  Diagnostic(
88
89
  node: member.nameToken,
89
90
  message: ReservedJSNameMessage(
90
- "The JavaScript name '\(member.jsName)' can't be bound: \(reason). Give the \(member.kind) another name with \(member.renaming)."
91
+ "The JavaScript name '\(jsName)' can't be bound: \(reason). Give the \(member.kind) another name with \(member.renaming)."
91
92
  )
92
93
  )
93
94
  )
@@ -129,7 +130,7 @@ internal func diagnoseEventNameCollisions(in memberBlock: MemberBlockSyntax, in
129
130
  }
130
131
 
131
132
  private struct BoundMember {
132
- let jsName: String
133
+ let key: JSMemberKey
133
134
  /// How the message names the member, such as `@JS function`.
134
135
  let kind: String
135
136
  /// The spelling that gives the member another JavaScript name.
@@ -138,18 +139,27 @@ private struct BoundMember {
138
139
 
139
140
  init(function: JSFunction) {
140
141
  // A `.withAsync` variant takes its JavaScript name from the option, not from `@JS("...")`.
141
- self.jsName = function.jsName
142
+ self.key = function.key
142
143
  self.kind = function.isAsyncVariant ? "'.withAsync' variant" : "@JS function"
143
- self.renaming = function.isAsyncVariant ? ".withAsync(\"...\")" : "@JS(\"...\")"
144
+ self.renaming = function.isAsyncVariant ? ".withAsync(\"...\")" : Self.renaming(of: function.key)
144
145
  self.nameToken = function.nameToken
145
146
  }
146
147
 
147
148
  init(property: JSProperty) {
148
- self.jsName = property.jsName
149
+ self.key = property.key
149
150
  self.kind = "@JS property"
150
- self.renaming = "@JS(\"...\")"
151
+ self.renaming = Self.renaming(of: property.key)
151
152
  self.nameToken = property.nameToken
152
153
  }
154
+
155
+ /// How the message names the key: `name` or `symbol`.
156
+ var keyKind: String {
157
+ return key.isSymbol ? "symbol" : "name"
158
+ }
159
+
160
+ private static func renaming(of key: JSMemberKey) -> String {
161
+ return key.isSymbol ? "@JS(symbol: ...)" : "@JS(\"...\")"
162
+ }
153
163
  }
154
164
 
155
165
  private struct JSNameCollisionMessage: DiagnosticMessage {
@@ -1,28 +1,68 @@
1
+ import SwiftParser
1
2
  import SwiftSyntax
2
3
 
3
4
  /// Reads the first string-literal argument of an attribute, e.g. `@JS("doWork")` -> "doWork".
4
- /// Returns nil if the attribute has no arguments or the first argument is not a string literal.
5
+ /// Returns nil if the attribute has no arguments or the first argument is not an unlabeled string
6
+ /// literal without interpolation, such as the `symbol:` argument of `@JS(symbol: "app.id")`. The
7
+ /// name is in the form of `literalContent(of:)`.
5
8
  internal func jsNameArgument(of attribute: AttributeSyntax) -> String? {
6
9
  guard let args = attribute.arguments?.as(LabeledExprListSyntax.self),
7
10
  let first = args.first,
8
- let str = first.expression.as(StringLiteralExprSyntax.self),
9
- let segment = str.segments.first?.as(StringSegmentSyntax.self)
11
+ first.label == nil,
12
+ let literal = first.expression.as(StringLiteralExprSyntax.self)
10
13
  else {
11
14
  return nil
12
15
  }
13
- return segment.content.text
16
+ return literalContent(of: literal)
17
+ }
18
+
19
+ /// The value of a string literal as the content of a plain Swift string literal: the value with its
20
+ /// escape sequences decoded, and escaped again by `swiftStringLiteral(_:)` without the quotes. So
21
+ /// the generated code can put it between quotes whatever the literal is: a raw string (`#"a"b"#`)
22
+ /// reads as `a\"b`, and literals with the same value (`"id"`, `"\u{69}d"`) read the same. `nil` for
23
+ /// a literal with interpolation.
24
+ internal func literalContent(of literal: StringLiteralExprSyntax) -> String? {
25
+ guard let value = literal.representedLiteralValue else {
26
+ return nil
27
+ }
28
+ return String(swiftStringLiteral(value).dropFirst().dropLast())
29
+ }
30
+
31
+ /// A Swift string literal whose value is `value`. A quote, a backslash and the control characters are
32
+ /// escaped, so any value gives a valid literal.
33
+ internal func swiftStringLiteral(_ value: String) -> String {
34
+ var literal = "\""
35
+ for scalar in value.unicodeScalars {
36
+ switch scalar {
37
+ case "\"":
38
+ literal += "\\\""
39
+ case "\\":
40
+ literal += "\\\\"
41
+ case "\n":
42
+ literal += "\\n"
43
+ case "\r":
44
+ literal += "\\r"
45
+ case "\t":
46
+ literal += "\\t"
47
+ case _ where scalar.value < 0x20 || scalar.value == 0x7F:
48
+ literal += "\\u{\(String(scalar.value, radix: 16))}"
49
+ default:
50
+ literal.unicodeScalars.append(scalar)
51
+ }
52
+ }
53
+ return literal + "\""
14
54
  }
15
55
 
16
56
  /// True if the attribute lists the given option member, e.g. `@JS(.concurrent)`, `@Event(.inline)` or
17
57
  /// `@Event("name", EventOptions.inline)` -> true for the matching name. Options are written as
18
58
  /// member-access expressions, one per variadic argument. Array literals are scanned too, though the
19
59
  /// variadic declarations in `expo-modules-core` don't accept them. The leading string literal, when
20
- /// present, is the JS name and is skipped.
60
+ /// present, is the JS name and is skipped, and so is the `symbol:` argument of `@JS`.
21
61
  internal func hasOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
22
62
  guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
23
63
  return false
24
64
  }
25
- for arg in args {
65
+ for arg in args where arg.label == nil {
26
66
  if namesOption(arg.expression, option) {
27
67
  return true
28
68
  }
@@ -88,18 +128,18 @@ private func asyncOptionCall(_ expression: ExprSyntax) -> FunctionCallExprSyntax
88
128
 
89
129
  /// The JavaScript name a `.withAsync("name")` option gives the promise-returning variant, with the
90
130
  /// token of its string literal, where a name collision is reported. `nil` when the argument isn't a
91
- /// single plain string literal, which `@JS` reports.
131
+ /// string literal without interpolation, which `@JS` reports. The name is in the form of
132
+ /// `literalContent(of:)`.
92
133
  internal func asyncVariantName(of option: FunctionCallExprSyntax) -> (name: String, token: TokenSyntax)? {
93
134
  guard option.arguments.count == 1,
94
135
  let argument = option.arguments.first,
95
136
  argument.label == nil,
96
137
  let literal = argument.expression.as(StringLiteralExprSyntax.self),
97
- literal.segments.count == 1,
98
- let segment = literal.segments.first?.as(StringSegmentSyntax.self)
138
+ let name = literalContent(of: literal)
99
139
  else {
100
140
  return nil
101
141
  }
102
- return (segment.content.text, segment.content)
142
+ return (name, literal.segments.first?.as(StringSegmentSyntax.self)?.content ?? literal.openingQuote)
103
143
  }
104
144
 
105
145
  /// True if the `@JS`-marked declaration lists `.withAsync("name")`. Such a function is left
@@ -563,7 +603,6 @@ internal func collectProperties(
563
603
  varDecl: VariableDeclSyntax,
564
604
  attribute: AttributeSyntax
565
605
  ) -> [JSProperty] {
566
- let jsNameOverride = jsNameArgument(of: attribute)
567
606
  // A `let` is never settable; only `var` bindings can carry a setter.
568
607
  let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
569
608
  let isLazy = isLazy(varDecl.modifiers)
@@ -580,7 +619,7 @@ internal func collectProperties(
580
619
  ?? binding.initializer.flatMap { inferredLiteralType(of: $0.value) }
581
620
  return JSProperty(
582
621
  swiftName: swiftName,
583
- jsName: jsNameOverride ?? withoutBackticks(swiftName),
622
+ key: JSMemberKey(attribute: attribute, defaultName: withoutBackticks(swiftName)),
584
623
  valueType: valueType,
585
624
  // A literal-inferred type never mentions `Any`, so only an explicit annotation can be free-form.
586
625
  freeForm: binding.typeAnnotation.flatMap { freeFormConversion(of: $0.type) },
package/build/types.d.ts CHANGED
@@ -177,6 +177,17 @@ export interface ExportedParameter {
177
177
  /** The default value as Swift source text (`1`, `"x"`, `.fast`), absent when none is written. */
178
178
  defaultValue?: string;
179
179
  }
180
+ /** The symbol that a `@JS(symbol: ...)` member binds under. */
181
+ export interface ExportedSymbol {
182
+ /**
183
+ * `wellKnown` for a well-known symbol such as `Symbol.iterator`, from `@JS(symbol: .iterator)`;
184
+ * `registered` for the symbol that the global registry holds for a key, `Symbol.for("app.id")`, from
185
+ * `@JS(symbol: "app.id")`.
186
+ */
187
+ kind: 'wellKnown' | 'registered';
188
+ /** The name of the well-known symbol, such as `iterator`, or the registry key. */
189
+ name: string;
190
+ }
180
191
  /** One `@JS func` on a module or shared object. */
181
192
  export interface ExportedFunction extends ExportedAccess, ExportedAvailability {
182
193
  /**
@@ -185,9 +196,14 @@ export interface ExportedFunction extends ExportedAccess, ExportedAvailability {
185
196
  name: string;
186
197
  /**
187
198
  * The JS name it binds under: the `@JS("x")` override, else `name`. The second entry of a function
188
- * with `.withAsync("x")` binds under `x`.
199
+ * with `.withAsync("x")` binds under `x`. Absent for a function bound under a symbol (see `symbol`).
189
200
  */
190
- jsName: string;
201
+ jsName?: string;
202
+ /**
203
+ * The symbol it binds under with `@JS(symbol: ...)`, absent when it binds under `jsName`. Exactly one
204
+ * of `jsName` and `symbol` is present.
205
+ */
206
+ symbol?: ExportedSymbol;
191
207
  parameters: ExportedParameter[];
192
208
  /** Absent for a `Void` return. */
193
209
  returns?: TypeNode;
@@ -198,7 +214,16 @@ export interface ExportedFunction extends ExportedAccess, ExportedAvailability {
198
214
  /** One `@JS var` on a module or shared object. */
199
215
  export interface ExportedProperty extends ExportedAccess, ExportedAvailability {
200
216
  name: string;
201
- jsName: string;
217
+ /**
218
+ * The JS name it binds under: the `@JS("x")` override, else `name`. Absent for a property bound
219
+ * under a symbol (see `symbol`).
220
+ */
221
+ jsName?: string;
222
+ /**
223
+ * The symbol it binds under with `@JS(symbol: ...)`, absent when it binds under `jsName`. Exactly one
224
+ * of `jsName` and `symbol` is present.
225
+ */
226
+ symbol?: ExportedSymbol;
202
227
  /** Absent when the type isn't determinable syntactically; the macro binds it getter-only. */
203
228
  type?: TypeNode;
204
229
  readonly: boolean;
@@ -401,7 +426,7 @@ export interface ExportedViewEvent extends ExportedAccess, ExportedAvailability
401
426
  export interface ExportedView extends ExportedAccess, ExportedAvailability {
402
427
  /** The Swift class name. Only a view declared at file scope is reported. */
403
428
  name: string;
404
- /** The name the view registers under. `@ExpoView` takes no name, so it is always the class name. */
429
+ /** The name the view registers under: the `@ExpoView<CardProps>("Foo")` override, else the class name. */
405
430
  jsName: string;
406
431
  /**
407
432
  * The props type: the generic argument of `@ExpoView<CardProps>`, as written without backticks
@@ -451,8 +476,8 @@ export interface ScanStats {
451
476
  export interface ScannedClass {
452
477
  name: string;
453
478
  /**
454
- * The resolved JS name: the `@ExpoModule("Foo")` or `@SharedObject("Foo")` override, else the class
455
- * name. A view's JS name is its class name.
479
+ * The resolved JS name: the `@ExpoModule("Foo")`, `@SharedObject("Foo")` or
480
+ * `@ExpoView<CardProps>("Foo")` override, else the class name.
456
481
  */
457
482
  jsName: string;
458
483
  /**
@@ -535,5 +560,5 @@ export interface ExpandResult {
535
560
  * as a clear error instead of silently misread fields.
536
561
  */
537
562
  export declare const SUPPORTED_SCAN_MODULES_SCHEMA_VERSION = 2;
538
- export declare const SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 11;
563
+ export declare const SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 12;
539
564
  export declare const SUPPORTED_EXPAND_SCHEMA_VERSION = 1;
package/build/types.js CHANGED
@@ -12,5 +12,5 @@ exports.SUPPORTED_EXPAND_SCHEMA_VERSION = exports.SUPPORTED_SCAN_EXPORTS_SCHEMA_
12
12
  * as a clear error instead of silently misread fields.
13
13
  */
14
14
  exports.SUPPORTED_SCAN_MODULES_SCHEMA_VERSION = 2;
15
- exports.SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 11;
15
+ exports.SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 12;
16
16
  exports.SUPPORTED_EXPAND_SCHEMA_VERSION = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-macros",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "Swift macro plugin for Expo modules",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -26,9 +26,9 @@
26
26
  "typescript": "^5.4.0"
27
27
  },
28
28
  "optionalDependencies": {
29
- "@expo/modules-macros-linux-arm64": "0.35.0",
30
- "@expo/modules-macros-linux-x64": "0.35.0",
31
- "@expo/modules-macros-win32-arm64": "0.35.0",
32
- "@expo/modules-macros-win32-x64": "0.35.0"
29
+ "@expo/modules-macros-linux-arm64": "0.36.0",
30
+ "@expo/modules-macros-linux-x64": "0.36.0",
31
+ "@expo/modules-macros-win32-arm64": "0.36.0",
32
+ "@expo/modules-macros-win32-x64": "0.36.0"
33
33
  }
34
34
  }