mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-19 14:07:22 +02:00
fix(ios-qa): generate app-owned bridge accessors deterministically
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
// swift-tools-version:5.9
|
||||
//
|
||||
// gen-accessors-tool — SwiftPM tool that reads an app's Swift source via
|
||||
// swift-syntax, finds @Observable classes with @Snapshotable-marked fields,
|
||||
// and emits StateAccessor.swift for each one.
|
||||
// swift-syntax, finds @Observable classes with `// @Snapshotable`-marked
|
||||
// fields, and emits StateAccessor.swift for each one.
|
||||
//
|
||||
// First build is 2-5 min on a cold machine (swift-syntax compile chain).
|
||||
// Subsequent runs are content-hash-cached and finish in ~50ms.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// gen-accessors entry point. Walks the input dir for *.swift files, parses
|
||||
// each via SwiftParser, finds @Observable classes with @Snapshotable-marked
|
||||
// properties, and emits StateAccessor.swift for each.
|
||||
// each via SwiftParser, finds @Observable classes with `// @Snapshotable`
|
||||
// source-marker comments (plus the legacy attribute form), and emits
|
||||
// StateAccessor.swift for each.
|
||||
//
|
||||
// Output goes to --output (default: same dir as input). Cache key is
|
||||
// computed from a composite hash and stored at
|
||||
@@ -15,6 +16,8 @@ struct AccessorSpec {
|
||||
let fields: [(name: String, typeText: String)]
|
||||
}
|
||||
|
||||
private let generatorFormatVersion = "accessor-generator-v5"
|
||||
|
||||
@main
|
||||
struct GenAccessors {
|
||||
static func main() async {
|
||||
@@ -32,38 +35,83 @@ struct GenAccessors {
|
||||
}()
|
||||
|
||||
// Walk + collect *.swift files
|
||||
guard let swiftFiles = collectSwiftFiles(at: inputDir) else {
|
||||
guard let swiftFiles = collectSwiftFiles(at: inputDir, excluding: outputDir) else {
|
||||
FileHandle.standardError.write(Data("input dir not found: \(inputDir)\n".utf8))
|
||||
exit(3)
|
||||
}
|
||||
|
||||
// Composite cache key — codex catch (source content alone misses
|
||||
// generator-logic changes).
|
||||
let cacheKey = computeCacheKey(swiftFiles: swiftFiles)
|
||||
let cacheDir = ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
|
||||
// Parse + validate before consulting the cache. Invalid marked fields
|
||||
// must fail deterministically rather than being hidden by an older
|
||||
// cached output.
|
||||
var specs: [AccessorSpec] = []
|
||||
var diagnostics: [String] = []
|
||||
for path in swiftFiles {
|
||||
guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
|
||||
let tree = Parser.parse(source: source)
|
||||
let visitor = ObservableClassVisitor(sourcePath: path, viewMode: .sourceAccurate)
|
||||
visitor.walk(tree)
|
||||
specs.append(contentsOf: visitor.specs)
|
||||
diagnostics.append(contentsOf: visitor.diagnostics)
|
||||
}
|
||||
var snapshotKeyOwners: [String: String] = [:]
|
||||
for spec in specs {
|
||||
for field in spec.fields {
|
||||
if let previous = snapshotKeyOwners[field.name] {
|
||||
diagnostics.append(
|
||||
"snapshot key '\(field.name)' is declared by both \(previous) and \(spec.className); "
|
||||
+ "keys must be unique across @Observable types"
|
||||
)
|
||||
} else {
|
||||
snapshotKeyOwners[field.name] = spec.className
|
||||
}
|
||||
}
|
||||
}
|
||||
if !diagnostics.isEmpty {
|
||||
let message = "gen-accessors: invalid @Snapshotable declaration(s):\n"
|
||||
+ diagnostics.map { " - \($0)" }.joined(separator: "\n") + "\n"
|
||||
FileHandle.standardError.write(Data(message.utf8))
|
||||
exit(4)
|
||||
}
|
||||
|
||||
let buildId = detectedBuildId()
|
||||
let accessorHash = computeAccessorHash(specs: specs)
|
||||
// Cache identity includes build provenance and generator ABI. The
|
||||
// separately computed accessorHash is schema-only and remains stable
|
||||
// across checkout paths, unrelated sources, and app rebuilds.
|
||||
let cacheKey = computeCacheKey(swiftFiles: swiftFiles, buildId: buildId)
|
||||
let cacheDir = getEnv("GSTACK_IOS_CACHE_ROOT")
|
||||
?? ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
|
||||
let cachedOutput = "\(cacheDir)/\(cacheKey)/StateAccessor.swift"
|
||||
do {
|
||||
try FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("gen-accessors: cannot create output directory: \(error)\n".utf8))
|
||||
exit(5)
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: cachedOutput) {
|
||||
// Cache hit. Copy to output dir.
|
||||
try? FileManager.default.removeItem(atPath: "\(outputDir)/StateAccessor.swift")
|
||||
try? FileManager.default.copyItem(atPath: cachedOutput, toPath: "\(outputDir)/StateAccessor.swift")
|
||||
let finalOutput = "\(outputDir)/StateAccessor.swift"
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: finalOutput) {
|
||||
try FileManager.default.removeItem(atPath: finalOutput)
|
||||
}
|
||||
try FileManager.default.copyItem(atPath: cachedOutput, toPath: finalOutput)
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("gen-accessors: cannot restore cached output: \(error)\n".utf8))
|
||||
exit(5)
|
||||
}
|
||||
print("gen-accessors: cache hit (\(cacheKey))")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse + extract specs
|
||||
var specs: [AccessorSpec] = []
|
||||
for path in swiftFiles {
|
||||
guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
|
||||
let tree = Parser.parse(source: source)
|
||||
let visitor = ObservableClassVisitor(viewMode: .sourceAccurate)
|
||||
visitor.walk(tree)
|
||||
specs.append(contentsOf: visitor.specs)
|
||||
}
|
||||
|
||||
// Emit
|
||||
let output = render(specs: specs, buildId: getEnv("APP_BUILD_ID") ?? "unknown", accessorHash: cacheKey)
|
||||
try? FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
|
||||
try? output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
|
||||
let output = render(specs: specs, buildId: buildId, accessorHash: accessorHash)
|
||||
do {
|
||||
try output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("gen-accessors: cannot write output: \(error)\n".utf8))
|
||||
exit(5)
|
||||
}
|
||||
|
||||
// Populate cache
|
||||
try? FileManager.default.createDirectory(atPath: "\(cacheDir)/\(cacheKey)", withIntermediateDirectories: true)
|
||||
@@ -72,46 +120,154 @@ struct GenAccessors {
|
||||
print("gen-accessors: wrote \(specs.count) accessor(s) to \(outputDir)/StateAccessor.swift")
|
||||
}
|
||||
|
||||
static func collectSwiftFiles(at path: String) -> [String]? {
|
||||
guard let enumerator = FileManager.default.enumerator(atPath: path) else { return nil }
|
||||
static func collectSwiftFiles(at path: String, excluding outputPath: String) -> [String]? {
|
||||
let inputURL = URL(fileURLWithPath: path).standardizedFileURL
|
||||
let outputURL = URL(fileURLWithPath: outputPath).standardizedFileURL
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: inputURL.path, isDirectory: &isDirectory),
|
||||
isDirectory.boolValue,
|
||||
let enumerator = FileManager.default.enumerator(
|
||||
at: inputURL,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: []
|
||||
) else { return nil }
|
||||
|
||||
// When --output is the input root, scan the root but still exclude
|
||||
// StateAccessor.swift. For a nested output, skip the entire subtree.
|
||||
let shouldExcludeOutputSubtree = outputURL.path != inputURL.path
|
||||
var files: [String] = []
|
||||
for case let f as String in enumerator {
|
||||
if f.hasSuffix(".swift") { files.append("\(path)/\(f)") }
|
||||
for case let fileURL as URL in enumerator {
|
||||
let normalized = fileURL.standardizedFileURL
|
||||
let values = try? normalized.resourceValues(forKeys: [.isDirectoryKey])
|
||||
if values?.isDirectory == true {
|
||||
if normalized.lastPathComponent == "DebugBridgeGenerated"
|
||||
|| (shouldExcludeOutputSubtree && normalized.path == outputURL.path) {
|
||||
enumerator.skipDescendants()
|
||||
}
|
||||
continue
|
||||
}
|
||||
guard normalized.pathExtension == "swift",
|
||||
normalized.lastPathComponent != "StateAccessor.swift" else { continue }
|
||||
files.append(normalized.path)
|
||||
}
|
||||
return files.sorted()
|
||||
}
|
||||
|
||||
static func computeCacheKey(swiftFiles: [String]) -> String {
|
||||
static func computeCacheKey(swiftFiles: [String], buildId: String) -> String {
|
||||
// Codex-flagged: hash must include Swift version, tool git rev, platform.
|
||||
let swiftVer = getEnv("SWIFT_VERSION") ?? "unknown"
|
||||
let toolRev = getEnv("GEN_ACCESSORS_REV") ?? "dev"
|
||||
let platform = "darwin-arm64" // simplified for the test harness
|
||||
var combined = "swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|"
|
||||
let toolRev = getEnv("GEN_ACCESSORS_REV") ?? generatorFormatVersion
|
||||
#if arch(arm64)
|
||||
let platform = "darwin-arm64"
|
||||
#elseif arch(x86_64)
|
||||
let platform = "darwin-x86_64"
|
||||
#else
|
||||
let platform = "darwin-unknown"
|
||||
#endif
|
||||
var combined = Data("\(generatorFormatVersion)|swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|build=\(buildId)|".utf8)
|
||||
for path in swiftFiles {
|
||||
if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) {
|
||||
combined += "\(path):\(data.count):\(data.sha256())|"
|
||||
// Do not include absolute checkout paths in cache identity.
|
||||
combined.append(Data("\(data.count):".utf8))
|
||||
combined.append(data)
|
||||
combined.append(Data("|".utf8))
|
||||
}
|
||||
}
|
||||
return combined.data(using: .utf8)!.sha256()
|
||||
return combined.sha256()
|
||||
}
|
||||
|
||||
static func computeAccessorHash(specs: [AccessorSpec]) -> String {
|
||||
var signature = "snapshot-schema-v1\n"
|
||||
for spec in specs {
|
||||
signature += "C\(spec.className.utf8.count):\(spec.className)\n"
|
||||
for field in spec.fields {
|
||||
signature += "F\(field.name.utf8.count):\(field.name)"
|
||||
signature += "T\(field.typeText.utf8.count):\(field.typeText)\n"
|
||||
}
|
||||
signature += "E\n"
|
||||
}
|
||||
return signature.sha256()
|
||||
}
|
||||
|
||||
static func render(specs: [AccessorSpec], buildId: String, accessorHash: String) -> String {
|
||||
var out = "// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n"
|
||||
out += "#if DEBUG\nimport Foundation\nimport DebugBridge\n\n"
|
||||
out += "#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n"
|
||||
if !specs.isEmpty {
|
||||
// JSONSerialization produces Foundation bridge objects. Direct
|
||||
// `as?` casts let NSNumber cross-cast between Bool and numeric
|
||||
// Swift types. Round-tripping through JSONDecoder enforces the
|
||||
// declared Codable shape (including every collection element)
|
||||
// and preserves a successful Optional nil as a double Optional.
|
||||
out += "private enum _GStackDebugBridgeSnapshotJSON {\n"
|
||||
out += " private struct Box<Value: Decodable>: Decodable {\n"
|
||||
out += " let value: Value\n"
|
||||
out += " }\n\n"
|
||||
out += " static func decode<Value: Decodable>(_ value: Any, as _: Value.Type) -> Value? {\n"
|
||||
out += " let object: [String: Any] = [\"value\": value]\n"
|
||||
out += " guard JSONSerialization.isValidJSONObject(object),\n"
|
||||
out += " let data = try? JSONSerialization.data(withJSONObject: object) else {\n"
|
||||
out += " return nil\n"
|
||||
out += " }\n"
|
||||
out += " do {\n"
|
||||
out += " return try JSONDecoder().decode(Box<Value>.self, from: data).value\n"
|
||||
out += " } catch {\n"
|
||||
out += " return nil\n"
|
||||
out += " }\n"
|
||||
out += " }\n"
|
||||
out += "}\n\n"
|
||||
}
|
||||
for spec in specs {
|
||||
out += "@MainActor\npublic enum \(spec.className)Accessor {\n"
|
||||
out += " public static func register(_ state: \(spec.className)) {\n"
|
||||
// Accessors live in the app target beside its usually-internal
|
||||
// state types. A public signature cannot expose an internal type.
|
||||
out += "@MainActor\nenum \(spec.className)Accessor {\n"
|
||||
out += " static func register(_ state: \(spec.className)) {\n"
|
||||
out += " StateServer.shared.register(\n"
|
||||
out += " buildId: \"\(buildId)\",\n"
|
||||
out += " accessorHash: \"\(accessorHash)\",\n"
|
||||
out += " atomicRestore: { _ in .ok }\n"
|
||||
out += " buildId: {\n"
|
||||
out += " let shortVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleShortVersionString\") as? String\n"
|
||||
out += " let bundleVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleVersion\") as? String\n"
|
||||
out += " if let shortVersion, let bundleVersion { return \"\\(shortVersion) (\\(bundleVersion))\" }\n"
|
||||
out += " return shortVersion ?? bundleVersion ?? \(String(reflecting: buildId))\n"
|
||||
out += " }(),\n"
|
||||
out += " accessorHash: \(String(reflecting: accessorHash)),\n"
|
||||
out += " atomicRestore: { keys, apply in\n"
|
||||
out += " // Validate every key and value before assignment.\n"
|
||||
out += " // StateServer invokes every model once with apply=false,\n"
|
||||
out += " // then applies every validated model with apply=true.\n"
|
||||
for (index, field) in spec.fields.enumerated() {
|
||||
let (name, typeText) = field
|
||||
out += " guard let raw\(index) = keys[\"\(name)\"] else {\n"
|
||||
out += " return .missingKey(\"\(name)\")\n"
|
||||
out += " }\n"
|
||||
out += " guard let restored\(index): \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(raw\(index), as: \(typeText).self) else {\n"
|
||||
out += " return .typeMismatch(\"\(name)\")\n"
|
||||
out += " }\n"
|
||||
}
|
||||
out += " if apply {\n"
|
||||
for (index, field) in spec.fields.enumerated() {
|
||||
out += " state.\(field.name) = restored\(index)\n"
|
||||
}
|
||||
out += " }\n"
|
||||
out += " return .ok\n"
|
||||
out += " }\n"
|
||||
out += " )\n"
|
||||
for (name, _) in spec.fields {
|
||||
for (name, typeText) in spec.fields {
|
||||
let wrapped = optionalWrappedType(typeText)
|
||||
out += " StateServer.shared.registerAccessor(\n"
|
||||
out += " key: \"\(name)\",\n"
|
||||
out += " type: \"Any\",\n"
|
||||
out += " read: { state.\(name) as Any? },\n"
|
||||
out += " write: { _ in false }\n"
|
||||
out += " type: \"\(typeText)\",\n"
|
||||
if wrapped != nil {
|
||||
out += " read: {\n"
|
||||
out += " guard let value = state.\(name) else { return NSNull() }\n"
|
||||
out += " return value as Any\n"
|
||||
out += " },\n"
|
||||
} else {
|
||||
out += " read: { state.\(name) as Any? },\n"
|
||||
}
|
||||
out += " write: { value in\n"
|
||||
out += " guard let typed: \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(value, as: \(typeText).self) else { return false }\n"
|
||||
out += " state.\(name) = typed\n"
|
||||
out += " return true\n"
|
||||
out += " }\n"
|
||||
out += " )\n"
|
||||
}
|
||||
out += " }\n}\n\n"
|
||||
@@ -123,6 +279,59 @@ struct GenAccessors {
|
||||
|
||||
final class ObservableClassVisitor: SyntaxVisitor {
|
||||
var specs: [AccessorSpec] = []
|
||||
var diagnostics: [String] = []
|
||||
private let sourcePath: String
|
||||
|
||||
init(sourcePath: String, viewMode: SyntaxTreeViewMode) {
|
||||
self.sourcePath = sourcePath
|
||||
super.init(viewMode: viewMode)
|
||||
}
|
||||
|
||||
private func hasSnapshotableMarker(_ declaration: VariableDeclSyntax) -> Bool {
|
||||
// Preserve compatibility with source that defines a custom attribute
|
||||
// or wrapper, even though that form conflicts with @Observable in
|
||||
// ordinary app models.
|
||||
if declaration.attributes.contains(where: { attribute in
|
||||
guard let attribute = attribute.as(AttributeSyntax.self) else { return false }
|
||||
return attribute.attributeName.trimmedDescription == "Snapshotable"
|
||||
}) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Match a standalone ordinary line-comment trivia piece exactly.
|
||||
// Avoid declaration.description/contains: both would accept prose
|
||||
// such as `// do not expose through @Snapshotable`.
|
||||
return declaration.leadingTrivia.contains { piece in
|
||||
guard case .lineComment(let text) = piece else { return false }
|
||||
return text.dropFirst(2).trimmingCharacters(in: .whitespaces) == "@Snapshotable"
|
||||
}
|
||||
}
|
||||
|
||||
private func enclosingScopeDescription(for node: ClassDeclSyntax) -> String? {
|
||||
var ancestor = Syntax(node).parent
|
||||
while let current = ancestor {
|
||||
if let declaration = current.as(ClassDeclSyntax.self) {
|
||||
return "class \(declaration.name.text)"
|
||||
}
|
||||
if let declaration = current.as(StructDeclSyntax.self) {
|
||||
return "struct \(declaration.name.text)"
|
||||
}
|
||||
if let declaration = current.as(EnumDeclSyntax.self) {
|
||||
return "enum \(declaration.name.text)"
|
||||
}
|
||||
if let declaration = current.as(ActorDeclSyntax.self) {
|
||||
return "actor \(declaration.name.text)"
|
||||
}
|
||||
if let declaration = current.as(ExtensionDeclSyntax.self) {
|
||||
return "extension \(declaration.extendedType.trimmedDescription)"
|
||||
}
|
||||
if current.is(CodeBlockSyntax.self) {
|
||||
return "a local scope"
|
||||
}
|
||||
ancestor = current.parent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
|
||||
// Look for @Observable attribute
|
||||
@@ -133,23 +342,88 @@ final class ObservableClassVisitor: SyntaxVisitor {
|
||||
guard isObservable else { return .visitChildren }
|
||||
|
||||
let className = node.name.text
|
||||
let markedMembers = node.memberBlock.members.compactMap {
|
||||
$0.decl.as(VariableDeclSyntax.self)
|
||||
}.filter(hasSnapshotableMarker)
|
||||
if !markedMembers.isEmpty, let enclosingScope = enclosingScopeDescription(for: node) {
|
||||
diagnostics.append(
|
||||
"\((sourcePath as NSString).lastPathComponent): nested @Observable class "
|
||||
+ "\(className) inside \(enclosingScope) is unsupported; "
|
||||
+ "move snapshot-enabled models to file scope"
|
||||
)
|
||||
// Continue walking so a more deeply nested marked model is also
|
||||
// diagnosed rather than silently omitted.
|
||||
return .visitChildren
|
||||
}
|
||||
var fields: [(String, String)] = []
|
||||
|
||||
for member in node.memberBlock.members {
|
||||
guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue }
|
||||
// Field must be marked @Snapshotable to be included
|
||||
let isSnapshotable = varDecl.attributes.contains(where: { attr in
|
||||
guard let attr = attr.as(AttributeSyntax.self) else { return false }
|
||||
return attr.attributeName.trimmedDescription == "Snapshotable"
|
||||
})
|
||||
guard isSnapshotable else { continue }
|
||||
// Field must opt in with the source marker (or legacy attribute).
|
||||
guard hasSnapshotableMarker(varDecl) else { continue }
|
||||
|
||||
let bindingName = varDecl.bindings.first?
|
||||
.pattern.as(IdentifierPatternSyntax.self)?.identifier.text ?? "<unknown>"
|
||||
let context = "\((sourcePath as NSString).lastPathComponent): \(className).\(bindingName)"
|
||||
if varDecl.bindingSpecifier.text == "let" {
|
||||
diagnostics.append("\(context) must be declared var, not let")
|
||||
continue
|
||||
}
|
||||
let modifierNames = varDecl.modifiers.map { modifier -> String in
|
||||
let detail = modifier.detail?.trimmedDescription ?? ""
|
||||
return modifier.name.text + detail
|
||||
}
|
||||
if modifierNames.contains(where: {
|
||||
$0 == "private" || $0 == "fileprivate"
|
||||
|| $0 == "private(set)" || $0 == "fileprivate(set)"
|
||||
}) {
|
||||
diagnostics.append("\(context) cannot be private, fileprivate, private(set), or fileprivate(set)")
|
||||
continue
|
||||
}
|
||||
if modifierNames.contains("static") || modifierNames.contains("class") {
|
||||
diagnostics.append("\(context) must be an instance property")
|
||||
continue
|
||||
}
|
||||
if varDecl.bindings.count != 1 {
|
||||
diagnostics.append("\(context) declaration must contain exactly one binding")
|
||||
continue
|
||||
}
|
||||
|
||||
for binding in varDecl.bindings {
|
||||
if let pattern = binding.pattern.as(IdentifierPatternSyntax.self) {
|
||||
let name = pattern.identifier.text
|
||||
let typeText = binding.typeAnnotation?.type.trimmedDescription ?? "Any"
|
||||
fields.append((name, typeText))
|
||||
guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else {
|
||||
diagnostics.append("\(context) only supports an identifier binding")
|
||||
continue
|
||||
}
|
||||
// An initializer plus an accessor block is a stored property
|
||||
// with observers. Without an initializer, conservatively
|
||||
// reject the block rather than emitting a setter for a
|
||||
// computed/read-only declaration.
|
||||
if binding.accessorBlock != nil && binding.initializer == nil {
|
||||
diagnostics.append("\(context) must be stored and writable")
|
||||
continue
|
||||
}
|
||||
guard let annotation = binding.typeAnnotation else {
|
||||
diagnostics.append("\(context) requires an explicit type annotation")
|
||||
continue
|
||||
}
|
||||
let name = pattern.identifier.text
|
||||
let typeText = annotation.type.trimmedDescription
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.joined(separator: " ")
|
||||
switch parseJSONSnapshotType(typeText) {
|
||||
case .valid:
|
||||
break
|
||||
case .implicitlyUnwrappedOptional:
|
||||
diagnostics.append("\(context) cannot use an implicitly unwrapped Optional type")
|
||||
continue
|
||||
case .nestedOptional:
|
||||
diagnostics.append("\(context) cannot use a nested Optional type")
|
||||
continue
|
||||
case .unsupported:
|
||||
diagnostics.append("\(context) uses unsupported non-JSON snapshot type '\(typeText)'")
|
||||
continue
|
||||
}
|
||||
fields.append((name, typeText))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +434,233 @@ final class ObservableClassVisitor: SyntaxVisitor {
|
||||
}
|
||||
}
|
||||
|
||||
private indirect enum JSONSnapshotType {
|
||||
case scalar
|
||||
case optional(JSONSnapshotType)
|
||||
case array(JSONSnapshotType)
|
||||
case dictionary(JSONSnapshotType)
|
||||
|
||||
var isOptional: Bool {
|
||||
if case .optional = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private enum JSONSnapshotTypeParseResult {
|
||||
case valid(JSONSnapshotType)
|
||||
case implicitlyUnwrappedOptional
|
||||
case nestedOptional
|
||||
case unsupported
|
||||
}
|
||||
|
||||
/// Parse the deliberately small set of Swift types that can make a lossless
|
||||
/// trip through JSONSerialization and JSONDecoder without application-defined
|
||||
/// encoding behavior. Type aliases and arbitrary Codable models are rejected:
|
||||
/// the bridge has no compiler/type-checker context in which to prove that they
|
||||
/// are JSON-native.
|
||||
private func parseJSONSnapshotType(_ source: String) -> JSONSnapshotTypeParseResult {
|
||||
let type = source.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !type.isEmpty else { return .unsupported }
|
||||
|
||||
if type.hasSuffix("!") {
|
||||
return .implicitlyUnwrappedOptional
|
||||
}
|
||||
|
||||
if type.hasSuffix("?") {
|
||||
let wrappedText = String(type.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch parseJSONSnapshotType(wrappedText) {
|
||||
case .valid(let wrapped):
|
||||
return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
|
||||
case let issue:
|
||||
return issue
|
||||
}
|
||||
}
|
||||
|
||||
if let bracketContents = outerDelimitedContents(type, open: "[", close: "]") {
|
||||
let dictionaryParts = splitTopLevel(bracketContents, separator: ":")
|
||||
if dictionaryParts.count == 1 {
|
||||
switch parseJSONSnapshotType(dictionaryParts[0]) {
|
||||
case .valid(let element): return .valid(.array(element))
|
||||
case let issue: return issue
|
||||
}
|
||||
}
|
||||
guard dictionaryParts.count == 2, isStringType(dictionaryParts[0]) else {
|
||||
return .unsupported
|
||||
}
|
||||
switch parseJSONSnapshotType(dictionaryParts[1]) {
|
||||
case .valid(let value): return .valid(.dictionary(value))
|
||||
case let issue: return issue
|
||||
}
|
||||
}
|
||||
|
||||
if let generic = genericTypeParts(type) {
|
||||
let base = compactTypeName(generic.base)
|
||||
if base == "Optional" || base == "Swift.Optional" {
|
||||
guard generic.arguments.count == 1 else { return .unsupported }
|
||||
switch parseJSONSnapshotType(generic.arguments[0]) {
|
||||
case .valid(let wrapped):
|
||||
return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
|
||||
case let issue:
|
||||
return issue
|
||||
}
|
||||
}
|
||||
if base == "Array" || base == "Swift.Array" {
|
||||
guard generic.arguments.count == 1 else { return .unsupported }
|
||||
switch parseJSONSnapshotType(generic.arguments[0]) {
|
||||
case .valid(let element): return .valid(.array(element))
|
||||
case let issue: return issue
|
||||
}
|
||||
}
|
||||
if base == "Dictionary" || base == "Swift.Dictionary" {
|
||||
guard generic.arguments.count == 2, isStringType(generic.arguments[0]) else {
|
||||
return .unsupported
|
||||
}
|
||||
switch parseJSONSnapshotType(generic.arguments[1]) {
|
||||
case .valid(let value): return .valid(.dictionary(value))
|
||||
case let issue: return issue
|
||||
}
|
||||
}
|
||||
return .unsupported
|
||||
}
|
||||
|
||||
let scalar = compactTypeName(type)
|
||||
let supportedScalars: Set<String> = [
|
||||
"String", "Swift.String",
|
||||
"Bool", "Swift.Bool",
|
||||
"Int", "Swift.Int", "Int8", "Swift.Int8", "Int16", "Swift.Int16",
|
||||
"Int32", "Swift.Int32", "Int64", "Swift.Int64",
|
||||
"UInt", "Swift.UInt", "UInt8", "Swift.UInt8", "UInt16", "Swift.UInt16",
|
||||
"UInt32", "Swift.UInt32", "UInt64", "Swift.UInt64",
|
||||
"Float", "Swift.Float", "Double", "Swift.Double",
|
||||
"CGFloat", "CoreGraphics.CGFloat",
|
||||
]
|
||||
return supportedScalars.contains(scalar) ? .valid(.scalar) : .unsupported
|
||||
}
|
||||
|
||||
private func isStringType(_ source: String) -> Bool {
|
||||
let type = compactTypeName(source)
|
||||
return type == "String" || type == "Swift.String"
|
||||
}
|
||||
|
||||
private func compactTypeName(_ source: String) -> String {
|
||||
source.filter { !$0.isWhitespace }
|
||||
}
|
||||
|
||||
private func outerDelimitedContents(_ source: String, open: Character, close: Character) -> String? {
|
||||
let characters = Array(source)
|
||||
guard characters.first == open, characters.last == close else { return nil }
|
||||
var depth = 0
|
||||
for (index, character) in characters.enumerated() {
|
||||
if character == open {
|
||||
depth += 1
|
||||
} else if character == close {
|
||||
depth -= 1
|
||||
guard depth >= 0 else { return nil }
|
||||
if depth == 0, index != characters.count - 1 { return nil }
|
||||
}
|
||||
}
|
||||
guard depth == 0 else { return nil }
|
||||
return String(characters.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
private func genericTypeParts(_ source: String) -> (base: String, arguments: [String])? {
|
||||
let characters = Array(source)
|
||||
guard let openIndex = characters.firstIndex(of: "<") else { return nil }
|
||||
var depth = 0
|
||||
var closeIndex: Int?
|
||||
for index in openIndex..<characters.count {
|
||||
if characters[index] == "<" {
|
||||
depth += 1
|
||||
} else if characters[index] == ">" {
|
||||
depth -= 1
|
||||
guard depth >= 0 else { return nil }
|
||||
if depth == 0 {
|
||||
closeIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
guard let closeIndex, closeIndex == characters.count - 1 else { return nil }
|
||||
let base = String(characters[..<openIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let argumentsText = String(characters[(openIndex + 1)..<closeIndex])
|
||||
let arguments = splitTopLevel(argumentsText, separator: ",")
|
||||
guard !base.isEmpty, !arguments.isEmpty else { return nil }
|
||||
return (base, arguments)
|
||||
}
|
||||
|
||||
private func splitTopLevel(_ source: String, separator: Character) -> [String] {
|
||||
let characters = Array(source)
|
||||
var angleDepth = 0
|
||||
var squareDepth = 0
|
||||
var parenDepth = 0
|
||||
var start = 0
|
||||
var parts: [String] = []
|
||||
|
||||
for (index, character) in characters.enumerated() {
|
||||
switch character {
|
||||
case "<": angleDepth += 1
|
||||
case ">": angleDepth -= 1
|
||||
case "[": squareDepth += 1
|
||||
case "]": squareDepth -= 1
|
||||
case "(": parenDepth += 1
|
||||
case ")": parenDepth -= 1
|
||||
default: break
|
||||
}
|
||||
if character == separator, angleDepth == 0, squareDepth == 0, parenDepth == 0 {
|
||||
parts.append(
|
||||
String(characters[start..<index]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
start = index + 1
|
||||
}
|
||||
}
|
||||
parts.append(String(characters[start...]).trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
return parts
|
||||
}
|
||||
|
||||
func getEnv(_ key: String) -> String? {
|
||||
ProcessInfo.processInfo.environment[key]
|
||||
}
|
||||
|
||||
func detectedBuildId() -> String {
|
||||
if let explicit = getEnv("APP_BUILD_ID"), !explicit.isEmpty { return explicit }
|
||||
let marketing = getEnv("MARKETING_VERSION")
|
||||
let build = getEnv("CURRENT_PROJECT_VERSION")
|
||||
if let marketing, let build, !marketing.isEmpty, !build.isEmpty {
|
||||
return "\(marketing) (\(build))"
|
||||
}
|
||||
if let build, !build.isEmpty { return build }
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func optionalWrappedType(_ typeText: String) -> String? {
|
||||
let type = typeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if type.hasSuffix("?") {
|
||||
let wrapped = String(type.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return wrapped.isEmpty ? nil : wrapped
|
||||
}
|
||||
|
||||
guard let optionalRange = type.range(of: #"^Optional\s*<"#, options: .regularExpression),
|
||||
let open = type[optionalRange].lastIndex(of: "<") else { return nil }
|
||||
let openIndex = type.distance(from: type.startIndex, to: open)
|
||||
var depth = 0
|
||||
for (offset, char) in type.enumerated() where offset >= openIndex {
|
||||
if char == "<" { depth += 1 }
|
||||
else if char == ">" {
|
||||
depth -= 1
|
||||
if depth == 0 {
|
||||
let close = type.index(type.startIndex, offsetBy: offset)
|
||||
guard type[type.index(after: close)...].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let wrappedStart = type.index(after: open)
|
||||
let wrapped = String(type[wrappedStart..<close]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return wrapped.isEmpty ? nil : wrapped
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
import CryptoKit
|
||||
|
||||
extension Data {
|
||||
|
||||
Reference in New Issue
Block a user