Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 23 additions & 7 deletions apple/Sources/ExpoModulesScanner/Core/SourceScan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<DetectedMacro>) -> (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<DetectedMacro>,
process: (_ source: String, _ file: String) -> Void
) -> ScanStats {
let clock = ContinuousClock()
let start = clock.now

var detections: [Detection] = []
var filesScanned = 0
var filesParsed = 0

Expand All @@ -29,13 +34,24 @@ func collectDetections(paths: [String], macros: Set<DetectedMacro>) -> (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<DetectedMacro>) -> (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
Expand Down
186 changes: 186 additions & 0 deletions apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift
Original file line number Diff line number Diff line change
@@ -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
}
47 changes: 47 additions & 0 deletions apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
Loading
Loading