// gen-accessors entry point. Walks the input dir for *.swift files, parses // 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 // ~/.gstack/cache/gen-accessors//StateAccessor.swift. import Foundation import SwiftSyntax import SwiftParser struct AccessorSpec { let className: String let fields: [(name: String, typeText: String)] } private let generatorFormatVersion = "accessor-generator-v5" @main struct GenAccessors { static func main() async { let args = CommandLine.arguments guard let inputIdx = args.firstIndex(of: "--input"), args.count > inputIdx + 1 else { FileHandle.standardError.write(Data("usage: gen-accessors --input [--output ]\n".utf8)) exit(2) } let inputDir = args[inputIdx + 1] let outputDir: String = { if let idx = args.firstIndex(of: "--output"), args.count > idx + 1 { return args[idx + 1] } return inputDir }() // Walk + collect *.swift files guard let swiftFiles = collectSwiftFiles(at: inputDir, excluding: outputDir) else { FileHandle.standardError.write(Data("input dir not found: \(inputDir)\n".utf8)) exit(3) } // 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. 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 } // Emit 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) try? output.write(toFile: cachedOutput, atomically: true, encoding: .utf8) print("gen-accessors: wrote \(specs.count) accessor(s) to \(outputDir)/StateAccessor.swift") } 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 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], 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") ?? 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)) { // Do not include absolute checkout paths in cache identity. combined.append(Data("\(data.count):".utf8)) combined.append(data) combined.append(Data("|".utf8)) } } 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 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: Decodable {\n" out += " let value: Value\n" out += " }\n\n" out += " static func decode(_ 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.self, from: data).value\n" out += " } catch {\n" out += " return nil\n" out += " }\n" out += " }\n" out += "}\n\n" } for spec in specs { // 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: {\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, typeText) in spec.fields { let wrapped = optionalWrappedType(typeText) out += " StateServer.shared.registerAccessor(\n" out += " key: \"\(name)\",\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" } out += "#endif\n" return out } } 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 let isObservable = node.attributes.contains(where: { attr in guard let attr = attr.as(AttributeSyntax.self) else { return false } return attr.attributeName.trimmedDescription == "Observable" }) 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 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 ?? "" 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 { 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)) } } if !fields.isEmpty { specs.append(AccessorSpec(className: className, fields: fields)) } return .visitChildren } } 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", "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.." { 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[.. [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.. 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.. String { SHA256.hash(data: self).map { String(format: "%02x", $0) }.joined() } } extension String { func sha256() -> String { Data(self.utf8).sha256() } }