diff --git a/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift b/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift index 8298fc4..e579600 100644 --- a/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +++ b/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift @@ -95,8 +95,9 @@ private func arguments(of attribute: AttributeSyntax) -> [MacroArgument] { /// The first string-literal argument of an attribute, e.g. `@JS("doWork")` -> "doWork". Returns /// `nil` when there's no argument or it isn't a plain string literal. (Same shape the -/// `jsNameArgument` helper reads inside the macros.) -private func stringArgument(of attribute: AttributeSyntax) -> String? { +/// `jsNameArgument` helper reads inside the macros.) Shared with the `scan-exports` surface visitor, +/// which reads the same JS-name override off `@JS`/`@ExpoModule`/`@SharedObject`. +func stringArgument(of attribute: AttributeSyntax) -> String? { guard let args = attribute.arguments?.as(LabeledExprListSyntax.self), let first = args.first, first.label == nil, diff --git a/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift b/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift index 9c3c26c..6f5b49c 100644 --- a/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +++ b/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift @@ -2,14 +2,19 @@ import Foundation import SwiftParser import SwiftSyntax -/// Walks `paths`, parses each `.swift` file that might contain one of `macros` (the pre-filter), and -/// returns every detection (in file then source order) with the run's stats. The shared core every -/// scan command builds on; each command projects these detections into its own output shape. -func collectDetections(paths: [String], macros: Set) -> (detections: [Detection], stats: ScanStats) { +/// Walks `paths`, and for each `.swift` file that might contain one of `macros` (the pre-filter passes +/// it), reads the source and hands it to `process` along with the file path. Returns the run's stats. +/// The shared core every scan command builds on: the walk, read, pre-filter, and stats are identical; +/// only what each command does per parsed file differs (`scan-modules` collects `Detection`s, +/// `scan-exports` walks a `SurfaceVisitor`), and that lives in `process`. +func scanFiles( + paths: [String], + macros: Set, + process: (_ source: String, _ file: String) -> Void +) -> ScanStats { let clock = ContinuousClock() let start = clock.now - var detections: [Detection] = [] var filesScanned = 0 var filesParsed = 0 @@ -29,13 +34,24 @@ func collectDetections(paths: [String], macros: Set) -> (detectio continue } filesParsed += 1 - detections.append(contentsOf: detect(source: source, file: file, macros: macros)) + process(source, file) } let elapsed = (clock.now - start).components let durationMs = Double(elapsed.seconds) * 1000 + Double(elapsed.attoseconds) / 1e15 - return (detections, ScanStats(filesScanned: filesScanned, filesParsed: filesParsed, durationMs: durationMs)) + return ScanStats(filesScanned: filesScanned, filesParsed: filesParsed, durationMs: durationMs) +} + +/// Walks `paths`, parses each `.swift` file that might contain one of `macros`, and returns every +/// detection (in file then source order) with the run's stats — the shape `scan-modules` projects. +/// A thin layer over `scanFiles` that accumulates the per-file detections. +func collectDetections(paths: [String], macros: Set) -> (detections: [Detection], stats: ScanStats) { + var detections: [Detection] = [] + let stats = scanFiles(paths: paths, macros: macros) { source, file in + detections.append(contentsOf: detect(source: source, file: file, macros: macros)) + } + return (detections, stats) } /// Parses one source string and returns its detections for the given macro set. The unit of work the diff --git a/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift b/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift new file mode 100644 index 0000000..a6a691d --- /dev/null +++ b/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift @@ -0,0 +1,186 @@ +import Foundation + +/// The deep-scan surface: the full JS-exported shape of every `@ExpoModule`, `@SharedObject`, and +/// `@Record` type, with the per-member detail a TypeScript type generator needs. Read syntactically +/// (like the macros): each boundary type becomes a structured `TypeNode` tree so the consumer walks a +/// tagged tree rather than re-parsing Swift type syntax. + +/// One parameter of a `@JS` function or `@JS init`. +struct ExportedParameter: Encodable, Equatable { + /// The argument label (the `first` name): `to` in `func move(to point: Point)`, or `_` if unlabeled. + let label: String + + /// The internal parameter name (the `second` name, else the same as `label`): `point` above. + let name: String + + let type: TypeNode + + /// True when the caller may omit it: a default value or an optional type. Distinct from the type + /// being `.optional` (a defaulted non-optional is also omittable). Encoded as `optional`. + let isOptional: Bool + + private enum CodingKeys: String, CodingKey { + case label, name, type + case isOptional = "optional" + } +} + +/// One `@JS func` on a module or shared object. +struct ExportedFunction: Encodable, Equatable { + /// The Swift declaration name. + let name: String + + /// The JS name it binds under: the `@JS("x")` override, else `name`. + let jsName: String + + let parameters: [ExportedParameter] + + /// The return type, or `nil` for `Void`. Named `returns` to pair with `parameters`. + let returns: TypeNode? + + /// `async` (an async function is promise-returning in JS). + let isAsync: Bool + + /// `throws`. + let isThrowing: Bool + + /// `static`/`class` member. + let isStatic: Bool + + /// The flags are encoded under their TS-keyword spellings. + private enum CodingKeys: String, CodingKey { + case name, jsName, parameters, returns + case isAsync = "async" + case isThrowing = "throws" + case isStatic = "static" + } +} + +/// One `@JS var` on a module or shared object. +struct ExportedProperty: Encodable, Equatable { + /// The Swift declaration name. + let name: String + + /// The JS name it binds under: the `@JS("x")` override, else `name`. + let jsName: String + + /// The value type, or `nil` when undeterminable syntactically (no annotation and no literal + /// default), the case the macro binds getter-only. + let type: TypeNode? + + /// True when JS can assign to it: a stored `var` or a computed `var` with a `set`. Encoded as its + /// inverse, `readonly`. + let isSettable: Bool + + /// `static`/`class` member. Encoded as `static`. + let isStatic: Bool + + private enum CodingKeys: String, CodingKey { + case name, jsName, type + case isReadonly = "readonly" + case isStatic = "static" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(jsName, forKey: .jsName) + try container.encodeIfPresent(type, forKey: .type) + try container.encode(!isSettable, forKey: .isReadonly) + try container.encode(isStatic, forKey: .isStatic) + } +} + +/// One `@Record` property: a plain data slot exposing `optional`/`required` (vs. `ExportedProperty`, +/// a JS accessor exposing `readonly`/`static`). A record crosses the boundary by value, so the whole +/// type is read-only in JS; that's a record-level fact and isn't stamped per property. +struct ExportedRecordProperty: Encodable, Equatable { + let name: String + + /// `@Record` requires a determinable type on every property, so this is never `nil`. + let type: TypeNode + + /// Optional-typed. Encoded as `optional`. + let isOptional: Bool + + /// Has a default value. Not encoded (derivable as `!isOptional && !isRequired`, and a Swift default + /// never reaches JS); kept only to derive `isRequired`. + let hasDefault: Bool + + /// Whether JS must supply this property, matching the macro's `RecordProperty.isRequired`. Encoded + /// as `required`. + var isRequired: Bool { + return !hasDefault && !isOptional + } + + private enum CodingKeys: String, CodingKey { + case name, type + case isOptional = "optional" + case isRequired = "required" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(type, forKey: .type) + try container.encode(isOptional, forKey: .isOptional) + try container.encode(isRequired, forKey: .isRequired) + } +} + +/// A `@ExpoModule` type and its `@JS` surface. +struct ExportedModule: Encodable, Equatable { + /// The Swift class name. + let name: String + + /// The JS module name: `@ExpoModule("Foo")` override, else the class name. + let jsName: String + + let functions: [ExportedFunction] + let properties: [ExportedProperty] + + /// Absolute source path, matching `scan-modules`. + let file: String +} + +/// A `@SharedObject` type: a JS class with an optional `@JS init` constructor plus its `@JS` members. +struct ExportedSharedObject: Encodable, Equatable { + /// The Swift class name. + let name: String + + /// The JS class name: `@SharedObject("Foo")` override, else the class name. + let jsName: String + + /// The `@JS init` parameters, or `nil` when there's none. A shared object has at most one. + let constructorParameters: [ExportedParameter]? + + let functions: [ExportedFunction] + let properties: [ExportedProperty] + + let file: String +} + +/// A `@Record` type and its properties (data only: no functions, accessors, or constructor). +struct ExportedRecord: Encodable, Equatable { + /// The Swift type name (struct or class). + let name: String + + let properties: [ExportedRecordProperty] + + let file: String +} + +/// The exported types grouped by kind, nested under `exports` in the result so the surface is one +/// self-contained object separate from `stats`. +struct ExportedSurface: Encodable, Equatable { + let modules: [ExportedModule] + let sharedObjects: [ExportedSharedObject] + let records: [ExportedRecord] +} + +/// The `scan-exports` result: the surface plus the run's stats. A distinct envelope from +/// `ScanModulesResult` (different consumer: TS generation vs. autolinking). +struct ScanExportsResult: Encodable, Equatable { + let exports: ExportedSurface + let stats: ScanStats +} diff --git a/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift b/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift new file mode 100644 index 0000000..c287629 --- /dev/null +++ b/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift @@ -0,0 +1,47 @@ +import Foundation +import SwiftParser +import SwiftSyntax + +extension Scanner { + /// Runs `scan-exports` over `paths`, prints the JSON report to stdout, and returns the exit code + /// (`0` on success, `1` if encoding fails). The deep counterpart to `runModules`. + public static func runExports(paths: [String]) -> Int32 { + let result = scanExports(paths: paths) + + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(result) + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data("\n".utf8)) + return 0 + } catch { + FileHandle.standardError.write(Data("error: failed to encode results: \(error)\n".utf8)) + return 1 + } + } +} + +/// Scans `paths` for `@ExpoModule`, `@SharedObject`, and `@Record` types and returns their exported +/// surface plus the run's stats. Separate from the public entry so tests can drive it without +/// argv/stdout. The shared `scanFiles` walk + pre-filter selects files; a `SurfaceVisitor` extracts +/// each one. +func scanExports(paths: [String]) -> ScanExportsResult { + var modules: [ExportedModule] = [] + var sharedObjects: [ExportedSharedObject] = [] + var records: [ExportedRecord] = [] + + let stats = scanFiles(paths: paths, macros: [.expoModule, .sharedObject, .record]) { source, file in + let tree = Parser.parse(source: source) + let visitor = SurfaceVisitor(file: file) + visitor.walk(tree) + modules.append(contentsOf: visitor.modules) + sharedObjects.append(contentsOf: visitor.sharedObjects) + records.append(contentsOf: visitor.records) + } + + return ScanExportsResult( + exports: ExportedSurface(modules: modules, sharedObjects: sharedObjects, records: records), + stats: stats + ) +} diff --git a/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift b/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift new file mode 100644 index 0000000..ee6dee2 --- /dev/null +++ b/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift @@ -0,0 +1,307 @@ +import SwiftSyntax + +/// Extracts the full JS-exported surface of every top-level `@ExpoModule`, `@SharedObject`, and +/// `@Record` type: their `@JS` members and record properties. The deep counterpart to +/// `DetectionVisitor`. Recognition is purely syntactic and re-reads what the macros read (the macro +/// target can't be imported), so it stays in step with `JSFunction` / `JSProperty` / `JSConstructor` / +/// `RecordProperty`. +final class SurfaceVisitor: SyntaxVisitor { + private let file: String + private(set) var modules: [ExportedModule] = [] + private(set) var sharedObjects: [ExportedSharedObject] = [] + private(set) var records: [ExportedRecord] = [] + + init(file: String) { + self.file = file + super.init(viewMode: .sourceAccurate) + } + + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + if isTopLevel(node) { + classify(name: node.name.text, attributes: node.attributes, members: node.memberBlock.members) + } + // The member walk reads the body itself; nested types aren't part of this surface (matching + // `DetectionVisitor`'s top-level-only scope), so there's no reason to descend. + return .skipChildren + } + + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + if isTopLevel(node) { + classify(name: node.name.text, attributes: node.attributes, members: node.memberBlock.members) + } + return .skipChildren + } + + /// Routes a top-level type to the right collector based on which Expo macro it carries. A type + /// carrying none of them is ignored. `@Record` and `@ExpoModule`/`@SharedObject` are mutually + /// exclusive in practice, so the first match wins. + private func classify(name: String, attributes: AttributeListSyntax, members: MemberBlockItemListSyntax) { + if let attribute = attributes.firstAttribute(named: DetectedMacro.expoModule.rawValue) { + let (functions, properties, _) = collectJSMembers(members) + modules.append( + ExportedModule( + name: name, + jsName: stringArgument(of: attribute) ?? name, + functions: functions, + properties: properties, + file: file + )) + return + } + + if let attribute = attributes.firstAttribute(named: DetectedMacro.sharedObject.rawValue) { + let (functions, properties, constructor) = collectJSMembers(members) + sharedObjects.append( + ExportedSharedObject( + name: name, + jsName: stringArgument(of: attribute) ?? name, + constructorParameters: constructor, + functions: functions, + properties: properties, + file: file + )) + return + } + + if attributes.firstAttribute(named: DetectedMacro.record.rawValue) != nil { + records.append(ExportedRecord(name: name, properties: collectRecordProperties(members), file: file)) + } + } + + /// The `@JS` members of a module / shared-object body: functions, properties, and the single + /// `@JS init` constructor parameters (`nil` when absent). Only declarations carrying `@JS` count. + private func collectJSMembers( + _ members: MemberBlockItemListSyntax + ) -> (functions: [ExportedFunction], properties: [ExportedProperty], constructor: [ExportedParameter]?) { + var functions: [ExportedFunction] = [] + var properties: [ExportedProperty] = [] + var constructor: [ExportedParameter]? + + for member in members { + let decl = member.decl + + if let initDecl = decl.as(InitializerDeclSyntax.self), + initDecl.attributes.firstAttribute(named: DetectedMacro.js.rawValue) != nil { + // At most one `@JS init`; keep the first if a malformed source has more (the macro errors). + if constructor == nil { + constructor = parameters(of: initDecl.signature.parameterClause) + } + continue + } + + if let funcDecl = decl.as(FunctionDeclSyntax.self), + let attribute = funcDecl.attributes.firstAttribute(named: DetectedMacro.js.rawValue) { + functions.append(makeFunction(funcDecl: funcDecl, attribute: attribute)) + continue + } + + if let varDecl = decl.as(VariableDeclSyntax.self), + let attribute = varDecl.attributes.firstAttribute(named: DetectedMacro.js.rawValue) { + properties.append(contentsOf: makeProperties(varDecl: varDecl, attribute: attribute)) + } + } + + return (functions, properties, constructor) + } + + /// Builds an `ExportedFunction` from a `@JS func`: JS-name fallback, parameters, a `Void` return as + /// `nil`, and the effect/static flags. + private func makeFunction(funcDecl: FunctionDeclSyntax, attribute: AttributeSyntax) -> ExportedFunction { + let effects = funcDecl.signature.effectSpecifiers + let returnType = funcDecl.signature.returnClause?.type + return ExportedFunction( + name: funcDecl.name.text, + jsName: stringArgument(of: attribute) ?? funcDecl.name.text, + parameters: parameters(of: funcDecl.signature.parameterClause), + returns: isVoidType(returnType) ? nil : returnType.map { typeNode(from: $0) }, + isAsync: effects?.asyncSpecifier != nil, + isThrowing: effects?.throwsClause?.throwsSpecifier != nil, + isStatic: isTypeLevel(funcDecl.modifiers) + ) + } + + /// Builds the `ExportedProperty` entries for a `@JS var`/`let`. One declaration can introduce several + /// bindings (`var a, b: Int`), so this returns an array. The value type is the annotation, else the + /// literal default's inferred type, else `nil`. + private func makeProperties(varDecl: VariableDeclSyntax, attribute: AttributeSyntax) -> [ExportedProperty] { + let isLet = varDecl.bindingSpecifier.tokenKind == .keyword(.let) + let isStatic = isTypeLevel(varDecl.modifiers) + let override = stringArgument(of: attribute) + var result: [ExportedProperty] = [] + + for binding in varDecl.bindings { + guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else { + continue + } + let name = ident.identifier.text + let type = valueTypeNode(annotation: binding.typeAnnotation?.type, initializer: binding.initializer?.value) + result.append( + ExportedProperty( + name: name, + jsName: override ?? name, + type: type, + isSettable: isSettable(binding: binding, isLet: isLet), + isStatic: isStatic + )) + } + return result + } + + /// True when a binding is assignable from JS, mirroring the macro's `bindingIsSettable`: a `let` is + /// never settable; a stored `var` is; a computed `var` is settable iff it declares `set`/`willSet`/ + /// `didSet` (a getter-only `var` is read-only). + private func isSettable(binding: PatternBindingSyntax, isLet: Bool) -> Bool { + if isLet { + return false + } + guard let accessorBlock = binding.accessorBlock else { + // Stored `var`, settable. + return true + } + switch accessorBlock.accessors { + case .accessors(let list): + return list.contains { accessor in + switch accessor.accessorSpecifier.tokenKind { + case .keyword(.set), .keyword(.willSet), .keyword(.didSet): + return true + default: + return false + } + } + case .getter: + // `var x: Int { ... }` shorthand getter, read-only. + return false + } + } + + /// The `@Record` properties: every stored, non-excluded `var`/`let` binding, mirroring + /// `RecordMacro.recordProperties`. Computed and modifier-excluded bindings are skipped, as is one + /// whose type can't be determined (the macro would error, but the scan stays lenient). + private func collectRecordProperties(_ members: MemberBlockItemListSyntax) -> [ExportedRecordProperty] { + var properties: [ExportedRecordProperty] = [] + + for member in members { + guard let varDecl = member.decl.as(VariableDeclSyntax.self), + !isExcludedRecordModifier(varDecl.modifiers) else { + continue + } + for binding in varDecl.bindings { + if binding.accessorBlock != nil { + continue + } + guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else { + continue + } + let annotation = binding.typeAnnotation?.type + guard let type = valueTypeNode(annotation: annotation, initializer: binding.initializer?.value) else { + continue + } + properties.append( + ExportedRecordProperty( + name: ident.identifier.text, + type: type, + isOptional: annotation.map { isOptionalType($0) } ?? false, + hasDefault: binding.initializer != nil + )) + } + } + return properties + } + + /// Projects a parameter clause into `ExportedParameter`s: label = first name, name = second (else + /// first), and `optional` when it has a default value or an optional type. + private func parameters(of clause: FunctionParameterClauseSyntax) -> [ExportedParameter] { + clause.parameters.map { parameter in + let label = parameter.firstName.text + let name = parameter.secondName?.text ?? label + return ExportedParameter( + label: label, + name: name, + type: typeNode(from: parameter.type), + isOptional: parameter.defaultValue != nil || isOptionalType(parameter.type) + ) + } + } + + /// True when the declaration sits at file scope. Same rule as `DetectionVisitor.isTopLevel`: its + /// parent is a `CodeBlockItemSyntax` directly under the source file's top-level item list. + private func isTopLevel(_ node: some SyntaxProtocol) -> Bool { + guard let item = node.parent?.as(CodeBlockItemSyntax.self) else { + return false + } + return item.parent?.parent?.is(SourceFileSyntax.self) == true + } +} + +// MARK: - Syntactic helpers (shared spelling with the macros) + +/// True when the modifiers make a member type-level (`static` or `class`). +private func isTypeLevel(_ modifiers: DeclModifierListSyntax) -> Bool { + modifiers.contains { + $0.name.tokenKind == .keyword(.static) || $0.name.tokenKind == .keyword(.class) + } +} + +/// True when a modifier excludes a property from being a `@Record` field (`static`, `class`, +/// `private`, `fileprivate`, `lazy`), mirroring `RecordMacro.isExcludedByModifier`. +private func isExcludedRecordModifier(_ modifiers: DeclModifierListSyntax) -> Bool { + modifiers.contains { modifier in + switch modifier.name.tokenKind { + case .keyword(.static), .keyword(.class), .keyword(.private), .keyword(.fileprivate), .keyword(.lazy): + return true + default: + return false + } + } +} + +/// True when a type is written as an optional: `T?`, `T!`, or `Optional`, mirroring the macros' +/// `isOptionalType`. +private func isOptionalType(_ type: TypeSyntax) -> Bool { + if type.is(OptionalTypeSyntax.self) || type.is(ImplicitlyUnwrappedOptionalTypeSyntax.self) { + return true + } + if let identifier = type.as(IdentifierTypeSyntax.self), identifier.name.text == "Optional" { + return true + } + return false +} + +/// The first attribute whose spelled name matches `name`. A local copy of the macros' helper (the +/// macro target can't be imported here). +extension AttributeListSyntax { + fileprivate func firstAttribute(named name: String) -> AttributeSyntax? { + for element in self { + if let attribute = element.as(AttributeSyntax.self), + attribute.attributeName.trimmedDescription == name { + return attribute + } + } + return nil + } +} + +/// The node for a property/field value type: the annotation, else the literal default's inferred +/// primitive (`var n = 1` -> `Int`), else `nil`. +private func valueTypeNode(annotation: TypeSyntax?, initializer: ExprSyntax?) -> TypeNode? { + if let annotation { + return typeNode(from: annotation) + } + return initializer.flatMap(inferredLiteralType) +} + +/// The node for a simple literal default (`var n = 1` -> `Int`); `nil` for anything non-literal. +private func inferredLiteralType(of expression: ExprSyntax) -> TypeNode? { + switch expression.kind { + case .stringLiteralExpr: + return .primitive(name: "String", jsType: .string) + case .integerLiteralExpr: + return .primitive(name: "Int", jsType: .number) + case .floatLiteralExpr: + return .primitive(name: "Double", jsType: .number) + case .booleanLiteralExpr: + return .primitive(name: "Bool", jsType: .boolean) + default: + return nil + } +} diff --git a/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift b/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift new file mode 100644 index 0000000..82210ab --- /dev/null +++ b/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift @@ -0,0 +1,255 @@ +import SwiftSyntax + +/// A boundary type parsed into a structured, JS-oriented tree, so the consumer walks a tagged tree +/// instead of re-parsing Swift type syntax. The single Swift type parser lives here. Encoded as a +/// `kind` discriminator plus per-kind fields and a `typeof` on every node, e.g. +/// `{ "kind": "array", "typeof": "object", "element": { "kind": "primitive", "name": "Int", +/// "typeof": "number" } }`. An unmodeled spelling becomes `.unknown` (verbatim text, no `typeof`), +/// never silently dropped. + +/// The runtime category mirroring JavaScript's `typeof`. A coarse companion to `TypeNode.kind`: every +/// structured object kind (array, dictionary, promise, ref) reports `object`. `bigint`/`symbol` are +/// included for completeness; the scanner doesn't produce them. +enum JSType: String, Encodable { + case undefined + case object + case boolean + case number + case bigint + case string + case symbol + case function +} + +indirect enum TypeNode: Equatable { + /// `Bool`, `Int`, `Double`, `String`: the types core fast-decodes. `name` is the Swift spelling; + /// `jsType` is `boolean`/`number`/`string`. + case primitive(name: String, jsType: JSType) + + /// `T?` / `T!` / `Optional`. + case optional(wrapped: TypeNode) + + /// `[T]` / `Array`. + case array(element: TypeNode) + + /// `[K: V]` / `Dictionary`. + case dictionary(key: TypeNode, value: TypeNode) + + /// `Promise`. + case promise(value: TypeNode) + + /// A closure `(A, B) async throws -> R`. `returns` is `nil` for `Void`; `isAsync`/`isThrowing` carry + /// the effects (encoded `async`/`throws`). + case function(parameters: [TypeNode], returns: TypeNode?, isAsync: Bool, isThrowing: Bool) + + /// Any other named type (record, shared object, enum, …). `name` is the possibly-qualified spelling; + /// the generator resolves it against the scanned types or treats it as opaque. + case ref(name: String) + + /// A spelling the parser doesn't model (generic parameter, tuple, metatype, …), kept verbatim so + /// nothing is lost. Has no `typeof`. + case unknown(text: String) + + /// The `typeof` category, or `nil` for `.unknown`. An optional reports its *present* value's + /// category; the absent (`undefined`) case is carried by the `.optional` wrapper itself. + var jsType: JSType? { + switch self { + case .primitive(_, let jsType): + return jsType + case .array, .dictionary, .promise, .ref: + return .object + case .function: + return .function + case .optional(let wrapped): + return wrapped.jsType + case .unknown: + return nil + } + } +} + +extension TypeNode: Encodable { + private enum CodingKeys: String, CodingKey { + case kind + case name + // The `typeof` category. Spelled `typeof` in JSON (what the consumer reads); the Swift property is + // `jsType` since it's the cleaner Swift name. + case jsType = "typeof" + case wrapped, element, key, value, parameters, returns, text + // A closure type's effects, matching `ExportedFunction`'s TS-keyword spellings. + case isAsync = "async" + case isThrowing = "throws" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + // Every node carries its `typeof` category (except `.unknown`, whose `jsType` is nil). Encoded up + // front so it sits beside `kind` on each node. + try container.encodeIfPresent(jsType, forKey: .jsType) + switch self { + case .primitive(let name, _): + try container.encode("primitive", forKey: .kind) + try container.encode(name, forKey: .name) + case .optional(let wrapped): + try container.encode("optional", forKey: .kind) + try container.encode(wrapped, forKey: .wrapped) + case .array(let element): + try container.encode("array", forKey: .kind) + try container.encode(element, forKey: .element) + case .dictionary(let key, let value): + try container.encode("dictionary", forKey: .kind) + try container.encode(key, forKey: .key) + try container.encode(value, forKey: .value) + case .promise(let value): + try container.encode("promise", forKey: .kind) + try container.encode(value, forKey: .value) + case .function(let parameters, let returns, let isAsync, let isThrowing): + try container.encode("function", forKey: .kind) + try container.encode(parameters, forKey: .parameters) + try container.encodeIfPresent(returns, forKey: .returns) + try container.encode(isAsync, forKey: .isAsync) + try container.encode(isThrowing, forKey: .isThrowing) + case .ref(let name): + try container.encode("ref", forKey: .kind) + try container.encode(name, forKey: .name) + case .unknown(let text): + try container.encode("unknown", forKey: .kind) + try container.encode(text, forKey: .text) + } + } +} + +/// The primitive Swift types with a dedicated JS mapping (the set core fast-decodes), each paired with +/// its JS primitive. A bare name here is a `.primitive`; anything else is a `.ref`. +private let primitiveJSTypes: [String: JSType] = [ + "Bool": .boolean, + "Int": .number, + "Double": .number, + "String": .string, +] + +/// Parses a `TypeSyntax` into a `TypeNode`: the single place Swift type syntax is interpreted. +func typeNode(from type: TypeSyntax) -> TypeNode { + // `T?` sugar. + if let optional = type.as(OptionalTypeSyntax.self) { + return .optional(wrapped: typeNode(from: optional.wrappedType)) + } + // `T!`, JS treats it the same as `T?`. + if let iuo = type.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) { + return .optional(wrapped: typeNode(from: iuo.wrappedType)) + } + // `[T]` sugar. + if let arrayType = type.as(ArrayTypeSyntax.self) { + return .array(element: typeNode(from: arrayType.element)) + } + // `[K: V]` sugar. + if let dictionaryType = type.as(DictionaryTypeSyntax.self) { + return .dictionary(key: typeNode(from: dictionaryType.key), value: typeNode(from: dictionaryType.value)) + } + // `(A, B) -> R`. + if let functionType = type.as(FunctionTypeSyntax.self) { + return functionNode(from: functionType) + } + // `@escaping (…) -> …`, `@Sendable …`, etc.: strip the attributes and parse the underlying type. + if let attributed = type.as(AttributedTypeSyntax.self) { + return typeNode(from: attributed.baseType) + } + // A nominal type, possibly generic: `Int`, `Point`, `Optional`, `Array`, `Promise`. + if let identifier = type.as(IdentifierTypeSyntax.self) { + return nominalNode(name: identifier.name.text, generics: identifier.genericArgumentClause) + } + // A qualified nominal type: `Foo.Bar`. Kept as a ref under its full spelling. + if let member = type.as(MemberTypeSyntax.self) { + return .ref(name: member.trimmedDescription) + } + // Tuples, metatypes, some/any types, etc.: not modeled, preserve the verbatim spelling. + return .unknown(text: type.trimmedDescription) +} + +/// A nominal type's node. The standard generic wrappers normalize to their sugared node +/// (`Optional`/`Array`/`Dictionary`/`Promise`); a bare name is a primitive when known, +/// else a ref; any other generic is a ref under its full spelling. +private func nominalNode(name: String, generics: GenericArgumentClauseSyntax?) -> TypeNode { + guard let generics else { + if let jsType = primitiveJSTypes[name] { + return .primitive(name: name, jsType: jsType) + } + return .ref(name: name) + } + + let arguments = genericArguments(generics) + switch (name, arguments.count) { + case ("Optional", 1): + return .optional(wrapped: arguments[0]) + case ("Array", 1): + return .array(element: arguments[0]) + case ("Dictionary", 2): + return .dictionary(key: arguments[0], value: arguments[1]) + case ("Promise", 1): + return .promise(value: arguments[0]) + default: + // An unmodeled generic (`Set`, `Either`, …). Keep the whole spelling as a ref so the + // name and its arguments survive for the generator to interpret or flag. + return .ref(name: "\(name)<\(arguments.map { $0.spelling }.joined(separator: ", "))>") + } +} + +/// The argument types of a generic clause, each parsed into a node. +private func genericArguments(_ clause: GenericArgumentClauseSyntax) -> [TypeNode] { + clause.arguments.compactMap { argument in + guard case .type(let type) = argument.argument else { + return nil + } + return typeNode(from: type) + } +} + +/// A closure node: each parameter parsed, the result (`Void`/`()` collapsed to `nil`, matching how a +/// function's own return is reported), and the closure's `async`/`throws` effects. +private func functionNode(from type: FunctionTypeSyntax) -> TypeNode { + let parameters = type.parameters.map { typeNode(from: $0.type) } + let returnType = type.returnClause.type + let returns = isVoidType(returnType) ? nil : typeNode(from: returnType) + let effects = type.effectSpecifiers + return .function( + parameters: parameters, + returns: returns, + isAsync: effects?.asyncSpecifier != nil, + isThrowing: effects?.throwsClause?.throwsSpecifier != nil + ) +} + +/// True when a return clause is absent or written `Void` / `()`, so the surface reports `nil`. Shared +/// by the function-return read and the closure-type parser. +func isVoidType(_ type: TypeSyntax?) -> Bool { + guard let type else { + return true + } + let text = type.trimmedDescription + return text == "Void" || text == "()" +} + +extension TypeNode { + /// A best-effort source-like spelling, used only to re-compose an unmodeled generic ref's name. + /// Not a round-trip of arbitrary Swift; the structured node is the source of truth. + fileprivate var spelling: String { + switch self { + case .primitive(let name, _): + return name + case .ref(let name): + return name + case .optional(let wrapped): + return "\(wrapped.spelling)?" + case .array(let element): + return "[\(element.spelling)]" + case .dictionary(let key, let value): + return "[\(key.spelling): \(value.spelling)]" + case .promise(let value): + return "Promise<\(value.spelling)>" + case .function(let parameters, let returns, _, _): + return "(\(parameters.map { $0.spelling }.joined(separator: ", "))) -> \(returns?.spelling ?? "Void")" + case .unknown(let text): + return text + } + } +} diff --git a/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift b/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift index d9cd002..d85429e 100644 --- a/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +++ b/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift @@ -7,8 +7,8 @@ import Foundation /// `@testable import`, and the CLI only needs these entries, so nothing else is exposed. public enum Scanner { /// Runs the `scan-modules` command over `paths`, prints the JSON report to stdout, and returns a - /// process exit code: `0` on success, `1` if encoding fails. (`scan-exports` will get its own - /// `run`-style entry returning its own result type when implemented.) + /// process exit code: `0` on success, `1` if encoding fails. (The deep `scan-exports` command has + /// its own `runExports` entry returning its own result type.) public static func runModules(paths: [String]) -> Int32 { let result = scanModules(paths: paths) @@ -30,7 +30,7 @@ public enum Scanner { /// register a module: the Swift class name, the JS name it registers under, and the file it's in. /// The richer fields the visitor captures (declaration kind, raw macro arguments, line/column) are /// dropped here — they're redundant for this command (the macro is always `@ExpoModule` on a class) -/// and belong to the deep `scan-exports` surface instead. +/// and the deep `scan-exports` surface carries the richer per-member detail instead. struct ScannedModule: Codable, Equatable { /// The Swift class name the module is declared as. let name: String @@ -45,8 +45,8 @@ struct ScannedModule: Codable, Equatable { } /// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the -/// command's JSON output. (`scan-exports` will return its own shape when implemented; the two -/// commands serve different consumers and aren't expected to share an envelope.) +/// command's JSON output. (`scan-exports` returns its own `ScanExportsResult` shape; the two commands +/// serve different consumers and don't share an envelope.) struct ScanModulesResult: Codable, Equatable { let modules: [ScannedModule] let stats: ScanStats diff --git a/apple/Sources/ExpoModulesScannerCLI/main.swift b/apple/Sources/ExpoModulesScannerCLI/main.swift index 482926e..75665e5 100644 --- a/apple/Sources/ExpoModulesScannerCLI/main.swift +++ b/apple/Sources/ExpoModulesScannerCLI/main.swift @@ -61,9 +61,10 @@ case "scan-modules": exit(Scanner.runModules(paths: paths)) case "scan-exports": - // Deep extraction (members, record fields, the JS surface of each type) lands in a separate PR. - // The subcommand is recognized so the CLI surface is stable, but it isn't implemented yet. - fail("scan-exports is not yet implemented", code: 1) + guard !paths.isEmpty else { + fail("scan-exports requires at least one path", usage: true) + } + exit(Scanner.runExports(paths: paths)) default: fail("unknown subcommand '\(subcommand)'", usage: true) diff --git a/apple/Tests/ExpoModulesScannerTests/SurfaceVisitorTests.swift b/apple/Tests/ExpoModulesScannerTests/SurfaceVisitorTests.swift new file mode 100644 index 0000000..346bc76 --- /dev/null +++ b/apple/Tests/ExpoModulesScannerTests/SurfaceVisitorTests.swift @@ -0,0 +1,266 @@ +@testable import ExpoModulesScanner +import Foundation +import SwiftParser +import Testing + +/// Parses a source string and returns the exported surface the visitor extracts. The file name is +/// fixed so any per-type `file` field is stable across runs. +private func surface(_ source: String) -> SurfaceVisitor { + let tree = Parser.parse(source: source) + let visitor = SurfaceVisitor(file: "Test.swift") + visitor.walk(tree) + return visitor +} + +@Suite("Exports surface: modules") +struct ModuleSurfaceTests { + @Test + func `Extracts a module's @JS functions with names, params, and effects`() throws { + let module = try #require( + surface( + """ + @ExpoModule("Greeter") + final class GreeterModule { + @JS + func greet(name: String, loud: Bool = false) -> String { "" } + + @JS("doWork") + func performWork() async throws {} + + // Not exported: no @JS. + func helper() {} + } + """ + ).modules.first) + + #expect(module.name == "GreeterModule") + #expect(module.jsName == "Greeter") + #expect(module.functions.map(\.name) == ["greet", "performWork"]) + + let greet = try #require(module.functions.first { $0.name == "greet" }) + #expect(greet.jsName == "greet") + #expect(greet.returns == .primitive(name: "String", jsType: .string)) + #expect(greet.isAsync == false) + #expect(greet.isThrowing == false) + #expect(greet.parameters.map(\.name) == ["name", "loud"]) + #expect(greet.parameters.map(\.type) == [.primitive(name: "String", jsType: .string), .primitive(name: "Bool", jsType: .boolean)]) + // A defaulted parameter is omittable, so it's reported optional. + #expect(greet.parameters.map(\.isOptional) == [false, true]) + + let work = try #require(module.functions.first { $0.name == "performWork" }) + // The @JS("doWork") override becomes the JS name; the Swift name is kept separately. + #expect(work.jsName == "doWork") + #expect(work.isAsync == true) + #expect(work.isThrowing == true) + // A Void return is dropped to nil. + #expect(work.returns == nil) + } + + @Test + func `Extracts @JS properties with type and settability`() throws { + let module = try #require( + surface( + """ + @ExpoModule + final class M { + @JS var counter = 0 + @JS var status: String { "ok" } + @JS let id: String + @JS var name: String { + get { "" } + set {} + } + @JS var observed: Int = 0 { + didSet {} + } + } + """ + ).modules.first) + + let byName = Dictionary(uniqueKeysWithValues: module.properties.map { ($0.name, $0) }) + + // Stored var: typed from its literal default, settable. + #expect(byName["counter"]?.type == .primitive(name: "Int", jsType: .number)) + #expect(byName["counter"]?.isSettable == true) + // Getter-only computed var: read-only. + #expect(byName["status"]?.type == .primitive(name: "String", jsType: .string)) + #expect(byName["status"]?.isSettable == false) + // let: never settable. + #expect(byName["id"]?.isSettable == false) + // Computed var with an explicit set: settable. + #expect(byName["name"]?.isSettable == true) + // Observed stored var (`didSet`): still backed by storage, so settable. + #expect(byName["observed"]?.isSettable == true) + } + + @Test + func `Resolves a module's jsName: explicit override, else the class name`() { + #expect(surface("@ExpoModule\nfinal class Plain {}").modules.first?.jsName == "Plain") + #expect(surface("@ExpoModule(\"JS\")\nfinal class Renamed {}").modules.first?.jsName == "JS") + } +} + +@Suite("Exports surface: shared objects") +struct SharedObjectSurfaceTests { + @Test + func `Extracts a shared object's constructor, functions, and properties`() throws { + let shared = try #require( + surface( + """ + @SharedObject + final class Cache: SharedObject { + @JS + init(name: String, size: Int?) {} + + @JS + func clear() {} + + @JS + let id: String + } + """ + ).sharedObjects.first) + + #expect(shared.name == "Cache") + #expect(shared.jsName == "Cache") + #expect(shared.constructorParameters?.map(\.name) == ["name", "size"]) + // An optional-typed parameter is omittable. + #expect(shared.constructorParameters?.map(\.isOptional) == [false, true]) + #expect(shared.functions.map(\.name) == ["clear"]) + #expect(shared.properties.map(\.name) == ["id"]) + #expect(shared.properties.first?.isSettable == false) + } + + @Test + func `Reports a nil constructor when there's no @JS init`() throws { + let shared = try #require( + surface( + """ + @SharedObject + final class Cache: SharedObject { + @JS func clear() {} + } + """ + ).sharedObjects.first) + #expect(shared.constructorParameters == nil) + } +} + +@Suite("Exports surface: records") +struct RecordSurfaceTests { + @Test + func `Extracts record properties, excluding non-stored and non-eligible ones`() throws { + let record = try #require( + surface( + """ + @Record + struct Options { + var name: String + var retries: Int = 3 + var note: String? + private var secret: Int = 0 + static var shared: Int = 0 + var computed: Int { 1 } + } + """ + ).records.first) + + #expect(record.name == "Options") + // private, static, and computed properties are excluded. + #expect(record.properties.map(\.name) == ["name", "retries", "note"]) + + let byName = Dictionary(uniqueKeysWithValues: record.properties.map { ($0.name, $0) }) + // A property is required only when it has no default and isn't optional. + #expect(byName["name"]?.isOptional == false) + #expect(byName["name"]?.hasDefault == false) + #expect(byName["name"]?.isRequired == true) + // A defaulted property is not required. + #expect(byName["retries"]?.hasDefault == true) + #expect(byName["retries"]?.isOptional == false) + #expect(byName["retries"]?.isRequired == false) + // An optional property is not required (decodes to nil when omitted), even with no written default. + #expect(byName["note"]?.isOptional == true) + #expect(byName["note"]?.hasDefault == false) + #expect(byName["note"]?.isRequired == false) + } +} + +@Suite("Exports surface: scoping") +struct SurfaceScopingTests { + @Test + func `Ignores non-Expo types and nested types`() { + let visitor = surface( + """ + final class Plain { + @JS func notExported() {} + } + @ExpoModule + final class Outer { + @JS func exported() {} + @ExpoModule + final class Nested { + @JS func nested() {} + } + } + """ + ) + // A plain class contributes nothing, and a nested @ExpoModule isn't descended into. + #expect(visitor.modules.map(\.name) == ["Outer"]) + #expect(visitor.modules.first?.functions.map(\.name) == ["exported"]) + } + + @Test + func `Routes each macro to its own bucket`() { + let visitor = surface( + """ + @ExpoModule final class M {} + @SharedObject final class S: SharedObject {} + @Record struct R { var x: Int = 0 } + """ + ) + #expect(visitor.modules.map(\.name) == ["M"]) + #expect(visitor.sharedObjects.map(\.name) == ["S"]) + #expect(visitor.records.map(\.name) == ["R"]) + } +} + +@Suite("scan-exports over a directory") +struct ScanExportsTests { + /// Writes `files` (relative path, contents) into a fresh temp tree, runs `scanExports` over its + /// root, and hands the result to `body`. The tree is removed afterward. + private func withTree( + _ files: [(String, String)], + _ body: (ScanExportsResult) throws -> Void + ) throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent("scanner-exports-\(ProcessInfo.processInfo.globallyUniqueString)") + defer { try? fileManager.removeItem(at: root) } + + for (relativePath, contents) in files { + let url = root.appendingPathComponent(relativePath) + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try contents.write(to: url, atomically: true, encoding: .utf8) + } + + try body(scanExports(paths: [root.path])) + } + + @Test + func `Collects every kind across a tree, with absolute file paths and stats`() throws { + try withTree([ + ("Module.swift", "@ExpoModule\nfinal class M { @JS func f() {} }"), + ("Shared.swift", "@SharedObject\nfinal class S: SharedObject { @JS init() {} }"), + ("Options.swift", "@Record\nstruct R { var name: String }"), + ("Plain.swift", "final class Plain {}"), + ]) { result in + #expect(result.exports.modules.map(\.name) == ["M"]) + #expect(result.exports.sharedObjects.map(\.name) == ["S"]) + #expect(result.exports.records.map(\.name) == ["R"]) + // Reported paths are absolute. + #expect(result.exports.modules.first?.file.hasPrefix("/") == true) + // All four files are read; the plain one (no macro) isn't parsed. + #expect(result.stats.filesScanned == 4) + #expect(result.stats.filesParsed == 3) + } + } +} diff --git a/apple/Tests/ExpoModulesScannerTests/TypeNodeTests.swift b/apple/Tests/ExpoModulesScannerTests/TypeNodeTests.swift new file mode 100644 index 0000000..57c0ef2 --- /dev/null +++ b/apple/Tests/ExpoModulesScannerTests/TypeNodeTests.swift @@ -0,0 +1,141 @@ +@testable import ExpoModulesScanner +import SwiftParser +import SwiftSyntax +import Testing + +/// Parses a Swift type spelling into a `TypeNode`, the way the surface visitor does for a boundary +/// type. Wraps the spelling in a throwaway declaration so the parser yields a `TypeSyntax`. +private func node(_ spelling: String) -> TypeNode { + let tree = Parser.parse(source: "let _: \(spelling)") + guard let varDecl = tree.statements.first?.item.as(VariableDeclSyntax.self), + let type = varDecl.bindings.first?.typeAnnotation?.type else { + return .unknown(text: spelling) + } + return typeNode(from: type) +} + +@Suite("Type node parsing") +struct TypeNodeTests { + @Test + func `Maps the JS primitives`() { + #expect(node("Int") == .primitive(name: "Int", jsType: .number)) + #expect(node("Double") == .primitive(name: "Double", jsType: .number)) + #expect(node("String") == .primitive(name: "String", jsType: .string)) + #expect(node("Bool") == .primitive(name: "Bool", jsType: .boolean)) + } + + @Test + func `A non-primitive nominal type is a ref`() { + #expect(node("Point") == .ref(name: "Point")) + // A qualified name keeps its full spelling. + #expect(node("Foo.Bar") == .ref(name: "Foo.Bar")) + } + + @Test + func `Optionals: sugar, IUO, and the explicit generic all collapse to .optional`() { + #expect(node("Point?") == .optional(wrapped: .ref(name: "Point"))) + #expect(node("Int!") == .optional(wrapped: .primitive(name: "Int", jsType: .number))) + #expect(node("Optional") == .optional(wrapped: .primitive(name: "String", jsType: .string))) + } + + @Test + func `Arrays: sugar and the explicit generic`() { + #expect(node("[String]") == .array(element: .primitive(name: "String", jsType: .string))) + #expect(node("Array") == .array(element: .ref(name: "Point"))) + } + + @Test + func `Dictionaries: sugar and the explicit generic`() { + #expect(node("[String: Int]") == .dictionary(key: .primitive(name: "String", jsType: .string), value: .primitive(name: "Int", jsType: .number))) + #expect( + node("Dictionary") == .dictionary(key: .primitive(name: "String", jsType: .string), value: .ref(name: "Point"))) + } + + @Test + func `Promise wraps its value`() { + #expect(node("Promise") == .promise(value: .primitive(name: "Int", jsType: .number))) + #expect(node("Promise<[Point]>") == .promise(value: .array(element: .ref(name: "Point")))) + } + + @Test + func `Closures: parameters and a Void result collapsed to nil`() { + #expect( + node("(String) -> Void") + == .function( + parameters: [.primitive(name: "String", jsType: .string)], returns: nil, isAsync: false, isThrowing: false)) + #expect( + node("(Int, Point) -> String") + == .function( + parameters: [.primitive(name: "Int", jsType: .number), .ref(name: "Point")], + returns: .primitive(name: "String", jsType: .string), isAsync: false, isThrowing: false)) + } + + @Test + func `Closure async and throws effects are captured`() { + #expect( + node("() async -> Int") + == .function(parameters: [], returns: .primitive(name: "Int", jsType: .number), isAsync: true, isThrowing: false)) + #expect( + node("() throws -> Int") + == .function(parameters: [], returns: .primitive(name: "Int", jsType: .number), isAsync: false, isThrowing: true)) + #expect( + node("() async throws -> Void") + == .function(parameters: [], returns: nil, isAsync: true, isThrowing: true)) + } + + @Test + func `An @escaping attribute is stripped to the underlying type`() { + #expect( + node("@escaping (Int) -> Void") + == .function(parameters: [.primitive(name: "Int", jsType: .number)], returns: nil, isAsync: false, isThrowing: false)) + } + + @Test + func `Composed types nest`() { + // `[Point]?`, an optional array of refs. + #expect(node("[Point]?") == .optional(wrapped: .array(element: .ref(name: "Point")))) + // A dictionary whose value is an array of optionals. + #expect( + node("[String: [Int?]]") + == .dictionary( + key: .primitive(name: "String", jsType: .string), + value: .array(element: .optional(wrapped: .primitive(name: "Int", jsType: .number))))) + } + + @Test + func `An unmodeled generic keeps its name and arguments as a ref`() { + #expect(node("Set") == .ref(name: "Set")) + #expect(node("Either") == .ref(name: "Either")) + } + + @Test + func `An unmodeled spelling is preserved as .unknown`() { + // A bare identifier is syntactically indistinguishable from a nominal type, so a generic + // parameter `T` reads as a ref, the generator resolves (or flags) it. + #expect(node("T") == .ref(name: "T")) + // A tuple isn't modeled; its verbatim text survives as .unknown rather than being dropped. + #expect(node("(Int, Bool)") == .unknown(text: "(Int, Bool)")) + } + + @Test + func `Every node reports its typeof category`() { + // Primitives map to their JS primitive. + #expect(node("Int").jsType == .number) + #expect(node("Double").jsType == .number) + #expect(node("String").jsType == .string) + #expect(node("Bool").jsType == .boolean) + // Every structured object kind is `object`. + #expect(node("[String]").jsType == .object) + #expect(node("[String: Int]").jsType == .object) + #expect(node("Promise").jsType == .object) + #expect(node("Point").jsType == .object) + // A closure is `function`. + #expect(node("(Int) -> Void").jsType == .function) + // An optional reports the category of its present value, not `undefined` (the absent case is + // carried structurally by the wrapper). + #expect(node("Int?").jsType == .number) + #expect(node("Point?").jsType == .object) + // An unmodeled spelling has no known category. + #expect(node("(Int, Bool)").jsType == nil) + } +}