mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 15:20:11 +02:00
feat(ios): gen-accessors codegen tool (SwiftPM + TS port)
Replaces fork's regex-based codegen with SwiftPM swift-syntax tool (production) plus a TS port (test + fast first-run). Composite cache key: sha256(source || swift_version || tool_git_rev || platform_triple). Codex flagged that source-only hash misses generator-logic changes — this hash invalidates correctly across all four dimensions. 20 tests cover the 3 known regex failure modes (computed properties, generics, multi-line types) plus full cache hit/miss/prune coverage.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
// 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.
|
||||
//
|
||||
// First build is 2-5 min on a cold machine (swift-syntax compile chain).
|
||||
// Subsequent runs are content-hash-cached and finish in ~50ms.
|
||||
//
|
||||
// Invocation:
|
||||
// swift run --package-path ios-qa/scripts/gen-accessors-tool \
|
||||
// gen-accessors --input <swift-source-dir> [--output <out-dir>]
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "gen-accessors-tool",
|
||||
platforms: [.macOS(.v13)],
|
||||
products: [
|
||||
.executable(name: "gen-accessors", targets: ["GenAccessors"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "510.0.0"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "GenAccessors",
|
||||
dependencies: [
|
||||
.product(name: "SwiftSyntax", package: "swift-syntax"),
|
||||
.product(name: "SwiftParser", package: "swift-syntax"),
|
||||
],
|
||||
path: "Sources/GenAccessors"
|
||||
),
|
||||
.testTarget(
|
||||
name: "GenAccessorsTests",
|
||||
dependencies: ["GenAccessors"],
|
||||
path: "Tests/GenAccessorsTests"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,179 @@
|
||||
// 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.
|
||||
//
|
||||
// Output goes to --output (default: same dir as input). Cache key is
|
||||
// computed from a composite hash and stored at
|
||||
// ~/.gstack/cache/gen-accessors/<hash>/StateAccessor.swift.
|
||||
|
||||
import Foundation
|
||||
import SwiftSyntax
|
||||
import SwiftParser
|
||||
|
||||
struct AccessorSpec {
|
||||
let className: String
|
||||
let fields: [(name: String, typeText: String)]
|
||||
}
|
||||
|
||||
@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 <dir> [--output <dir>]\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) 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
|
||||
let cachedOutput = "\(cacheDir)/\(cacheKey)/StateAccessor.swift"
|
||||
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")
|
||||
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)
|
||||
|
||||
// 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) -> [String]? {
|
||||
guard let enumerator = FileManager.default.enumerator(atPath: path) else { return nil }
|
||||
var files: [String] = []
|
||||
for case let f as String in enumerator {
|
||||
if f.hasSuffix(".swift") { files.append("\(path)/\(f)") }
|
||||
}
|
||||
return files.sorted()
|
||||
}
|
||||
|
||||
static func computeCacheKey(swiftFiles: [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)|"
|
||||
for path in swiftFiles {
|
||||
if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) {
|
||||
combined += "\(path):\(data.count):\(data.sha256())|"
|
||||
}
|
||||
}
|
||||
return combined.data(using: .utf8)!.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"
|
||||
for spec in specs {
|
||||
out += "@MainActor\npublic enum \(spec.className)Accessor {\n"
|
||||
out += " public 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 += " )\n"
|
||||
for (name, _) in spec.fields {
|
||||
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 += " )\n"
|
||||
}
|
||||
out += " }\n}\n\n"
|
||||
}
|
||||
out += "#endif\n"
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
final class ObservableClassVisitor: SyntaxVisitor {
|
||||
var specs: [AccessorSpec] = []
|
||||
|
||||
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
|
||||
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 }
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !fields.isEmpty {
|
||||
specs.append(AccessorSpec(className: className, fields: fields))
|
||||
}
|
||||
return .visitChildren
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(_ key: String) -> String? {
|
||||
ProcessInfo.processInfo.environment[key]
|
||||
}
|
||||
|
||||
import CryptoKit
|
||||
|
||||
extension Data {
|
||||
func sha256() -> String {
|
||||
SHA256.hash(data: self).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
func sha256() -> String {
|
||||
Data(self.utf8).sha256()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// Tests for the gen-accessors TS port. Covers:
|
||||
//
|
||||
// - Parse: 3 regex-failure-mode fixtures from the fork (codex catch)
|
||||
// - Cache: same input → same key; different swift version → different key;
|
||||
// different tool rev → different key; file modified → different key
|
||||
// - Prune: >30d entries removed, recent kept
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync, utimesSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
collectSwiftFiles,
|
||||
parseSwift,
|
||||
computeCacheKey,
|
||||
generate,
|
||||
pruneCache,
|
||||
render,
|
||||
type AccessorSpec,
|
||||
} from './gen-accessors';
|
||||
|
||||
let workDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), 'gen-accessors-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('parseSwift — fork regex-failure-mode fixtures', () => {
|
||||
test('parses @Observable class with simple @Snapshotable fields', () => {
|
||||
const src = `
|
||||
@Observable
|
||||
final class AppState {
|
||||
@Snapshotable var isLoggedIn: Bool = false
|
||||
@Snapshotable var username: String = ""
|
||||
var notSnapshotable: Int = 0
|
||||
}
|
||||
`;
|
||||
const specs = parseSwift(src);
|
||||
expect(specs).toHaveLength(1);
|
||||
expect(specs[0]!.className).toBe('AppState');
|
||||
expect(specs[0]!.fields.map(f => f.name)).toEqual(['isLoggedIn', 'username']);
|
||||
expect(specs[0]!.fields.find(f => f.name === 'isLoggedIn')!.typeText).toBe('Bool');
|
||||
});
|
||||
|
||||
test('handles @Snapshotable on multi-line type signatures', () => {
|
||||
const src = `
|
||||
@Observable
|
||||
class Cart {
|
||||
@Snapshotable var items:
|
||||
[CartItem<Detail>]
|
||||
= []
|
||||
var unrelated: Int = 0
|
||||
}
|
||||
`;
|
||||
const specs = parseSwift(src);
|
||||
expect(specs).toHaveLength(1);
|
||||
expect(specs[0]!.fields).toHaveLength(1);
|
||||
expect(specs[0]!.fields[0]!.name).toBe('items');
|
||||
expect(specs[0]!.fields[0]!.typeText).toContain('CartItem');
|
||||
});
|
||||
|
||||
test('handles generic types in property signatures', () => {
|
||||
const src = `
|
||||
@Observable
|
||||
class Repo {
|
||||
@Snapshotable var pages: Dictionary<String, [Result<Item, Error>]> = [:]
|
||||
}
|
||||
`;
|
||||
const specs = parseSwift(src);
|
||||
expect(specs).toHaveLength(1);
|
||||
expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
|
||||
expect(specs[0]!.fields[0]!.typeText).toContain('Result');
|
||||
});
|
||||
|
||||
test('ignores fields without @Snapshotable marker', () => {
|
||||
const src = `
|
||||
@Observable
|
||||
class M {
|
||||
var plain: Int = 0
|
||||
@State var stateBacked: String = ""
|
||||
}
|
||||
`;
|
||||
const specs = parseSwift(src);
|
||||
expect(specs).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ignores non-@Observable classes', () => {
|
||||
const src = `
|
||||
class Plain {
|
||||
@Snapshotable var should: Int = 0
|
||||
}
|
||||
`;
|
||||
const specs = parseSwift(src);
|
||||
expect(specs).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('handles multiple @Observable classes in one file', () => {
|
||||
const src = `
|
||||
@Observable
|
||||
class A {
|
||||
@Snapshotable var a: Int = 0
|
||||
}
|
||||
@Observable
|
||||
class B {
|
||||
@Snapshotable var b: String = ""
|
||||
}
|
||||
`;
|
||||
const specs = parseSwift(src);
|
||||
expect(specs).toHaveLength(2);
|
||||
expect(specs.map(s => s.className).sort()).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
test('skips fields with computed body braces', () => {
|
||||
// Codex flagged "Properties with computed getters / didSet blocks" as a
|
||||
// failure mode of the fork's regex. We deliberately exclude them here —
|
||||
// computed properties are not snapshot-eligible.
|
||||
const src = `
|
||||
@Observable
|
||||
class M {
|
||||
@Snapshotable var snapshotted: Int = 0
|
||||
@Snapshotable var computed: Int {
|
||||
get { 42 }
|
||||
}
|
||||
}
|
||||
`;
|
||||
const specs = parseSwift(src);
|
||||
expect(specs).toHaveLength(1);
|
||||
expect(specs[0]!.fields.map(f => f.name)).toEqual(['snapshotted']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeCacheKey', () => {
|
||||
test('same source + same versioning = same key', () => {
|
||||
const f = join(workDir, 'a.swift');
|
||||
writeFileSync(f, '@Observable class A {}');
|
||||
const k1 = computeCacheKey({
|
||||
swiftFiles: [f],
|
||||
swiftVersion: '6.0.0',
|
||||
toolGitRev: 'abc123',
|
||||
platformTriple: 'darwin-arm64',
|
||||
});
|
||||
const k2 = computeCacheKey({
|
||||
swiftFiles: [f],
|
||||
swiftVersion: '6.0.0',
|
||||
toolGitRev: 'abc123',
|
||||
platformTriple: 'darwin-arm64',
|
||||
});
|
||||
expect(k1).toBe(k2);
|
||||
});
|
||||
|
||||
test('source modification changes the key', () => {
|
||||
const f = join(workDir, 'a.swift');
|
||||
writeFileSync(f, '@Observable class A {}');
|
||||
const k1 = computeCacheKey({
|
||||
swiftFiles: [f],
|
||||
swiftVersion: '6.0.0',
|
||||
toolGitRev: 'abc123',
|
||||
platformTriple: 'darwin-arm64',
|
||||
});
|
||||
writeFileSync(f, '@Observable class A { @Snapshotable var x: Int = 0 }');
|
||||
const k2 = computeCacheKey({
|
||||
swiftFiles: [f],
|
||||
swiftVersion: '6.0.0',
|
||||
toolGitRev: 'abc123',
|
||||
platformTriple: 'darwin-arm64',
|
||||
});
|
||||
expect(k1).not.toBe(k2);
|
||||
});
|
||||
|
||||
test('swift version change invalidates the key (codex catch)', () => {
|
||||
const f = join(workDir, 'a.swift');
|
||||
writeFileSync(f, '@Observable class A {}');
|
||||
const k1 = computeCacheKey({
|
||||
swiftFiles: [f],
|
||||
swiftVersion: '5.9.0',
|
||||
toolGitRev: 'abc',
|
||||
platformTriple: 'darwin-arm64',
|
||||
});
|
||||
const k2 = computeCacheKey({
|
||||
swiftFiles: [f],
|
||||
swiftVersion: '6.0.0',
|
||||
toolGitRev: 'abc',
|
||||
platformTriple: 'darwin-arm64',
|
||||
});
|
||||
expect(k1).not.toBe(k2);
|
||||
});
|
||||
|
||||
test('generator git rev change invalidates the key (codex catch)', () => {
|
||||
const f = join(workDir, 'a.swift');
|
||||
writeFileSync(f, '@Observable class A {}');
|
||||
const k1 = computeCacheKey({
|
||||
swiftFiles: [f],
|
||||
swiftVersion: '6.0.0',
|
||||
toolGitRev: 'abc123',
|
||||
platformTriple: 'darwin-arm64',
|
||||
});
|
||||
const k2 = computeCacheKey({
|
||||
swiftFiles: [f],
|
||||
swiftVersion: '6.0.0',
|
||||
toolGitRev: 'def456',
|
||||
platformTriple: 'darwin-arm64',
|
||||
});
|
||||
expect(k1).not.toBe(k2);
|
||||
});
|
||||
|
||||
test('platform triple change invalidates the key', () => {
|
||||
const f = join(workDir, 'a.swift');
|
||||
writeFileSync(f, '@Observable class A {}');
|
||||
const k1 = computeCacheKey({
|
||||
swiftFiles: [f],
|
||||
swiftVersion: '6.0.0',
|
||||
toolGitRev: 'abc',
|
||||
platformTriple: 'darwin-arm64',
|
||||
});
|
||||
const k2 = computeCacheKey({
|
||||
swiftFiles: [f],
|
||||
swiftVersion: '6.0.0',
|
||||
toolGitRev: 'abc',
|
||||
platformTriple: 'darwin-x86_64',
|
||||
});
|
||||
expect(k1).not.toBe(k2);
|
||||
});
|
||||
|
||||
test('adding/removing files invalidates the key', () => {
|
||||
const f1 = join(workDir, 'a.swift');
|
||||
const f2 = join(workDir, 'b.swift');
|
||||
writeFileSync(f1, '@Observable class A {}');
|
||||
writeFileSync(f2, '@Observable class B {}');
|
||||
const k1 = computeCacheKey({
|
||||
swiftFiles: [f1],
|
||||
swiftVersion: '6.0.0',
|
||||
toolGitRev: 'a',
|
||||
platformTriple: 'd-arm64',
|
||||
});
|
||||
const k2 = computeCacheKey({
|
||||
swiftFiles: [f1, f2],
|
||||
swiftVersion: '6.0.0',
|
||||
toolGitRev: 'a',
|
||||
platformTriple: 'd-arm64',
|
||||
});
|
||||
expect(k1).not.toBe(k2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generate', () => {
|
||||
test('first run writes StateAccessor.swift and populates cache', () => {
|
||||
const inputDir = join(workDir, 'src');
|
||||
mkdirSync(inputDir);
|
||||
writeFileSync(join(inputDir, 'state.swift'), `
|
||||
@Observable
|
||||
class AppState {
|
||||
@Snapshotable var x: Int = 0
|
||||
}
|
||||
`);
|
||||
const cacheRoot = join(workDir, 'cache');
|
||||
const r = generate({
|
||||
inputDir,
|
||||
cacheRoot,
|
||||
swiftVersion: '6.0.0',
|
||||
toolGitRev: 'test',
|
||||
platformTriple: 'darwin-arm64',
|
||||
});
|
||||
expect(r.cacheHit).toBe(false);
|
||||
expect(r.specs).toHaveLength(1);
|
||||
expect(r.specs[0]!.className).toBe('AppState');
|
||||
expect(existsSync(r.outputPath)).toBe(true);
|
||||
expect(existsSync(join(cacheRoot, r.cacheKey, 'StateAccessor.swift'))).toBe(true);
|
||||
});
|
||||
|
||||
test('second run with same inputs hits the cache', () => {
|
||||
const inputDir = join(workDir, 'src');
|
||||
mkdirSync(inputDir);
|
||||
writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
|
||||
const cacheRoot = join(workDir, 'cache');
|
||||
const r1 = generate({ inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' });
|
||||
const r2 = generate({ inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' });
|
||||
expect(r1.cacheHit).toBe(false);
|
||||
expect(r2.cacheHit).toBe(true);
|
||||
expect(r1.cacheKey).toBe(r2.cacheKey);
|
||||
});
|
||||
|
||||
test('modifying source invalidates the cache', () => {
|
||||
const inputDir = join(workDir, 'src');
|
||||
mkdirSync(inputDir);
|
||||
const file = join(inputDir, 'state.swift');
|
||||
writeFileSync(file, '@Observable class A { @Snapshotable var x: Int = 0 }');
|
||||
const cacheRoot = join(workDir, 'cache');
|
||||
const r1 = generate({ inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' });
|
||||
writeFileSync(file, '@Observable class A { @Snapshotable var y: String = "" }');
|
||||
const r2 = generate({ inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' });
|
||||
expect(r1.cacheKey).not.toBe(r2.cacheKey);
|
||||
expect(r2.cacheHit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneCache', () => {
|
||||
test('removes entries older than 30d, keeps recent', () => {
|
||||
const cacheRoot = join(workDir, 'cache');
|
||||
mkdirSync(cacheRoot, { recursive: true });
|
||||
const old = join(cacheRoot, 'old-key');
|
||||
const fresh = join(cacheRoot, 'fresh-key');
|
||||
mkdirSync(old);
|
||||
mkdirSync(fresh);
|
||||
writeFileSync(join(old, 'StateAccessor.swift'), '// old');
|
||||
writeFileSync(join(fresh, 'StateAccessor.swift'), '// fresh');
|
||||
|
||||
// Backdate the old dir by 60 days.
|
||||
const sixtyDaysAgo = (Date.now() - 60 * 24 * 60 * 60 * 1000) / 1000;
|
||||
utimesSync(old, sixtyDaysAgo, sixtyDaysAgo);
|
||||
|
||||
const { pruned } = pruneCache(cacheRoot, 30);
|
||||
expect(pruned).toHaveLength(1);
|
||||
expect(pruned[0]).toBe(old);
|
||||
expect(existsSync(old)).toBe(false);
|
||||
expect(existsSync(fresh)).toBe(true);
|
||||
});
|
||||
|
||||
test('no-op on empty cache dir', () => {
|
||||
const { pruned } = pruneCache(join(workDir, 'nope'), 30);
|
||||
expect(pruned).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('render', () => {
|
||||
test('emits valid-looking Swift for one class with two fields', () => {
|
||||
const specs: AccessorSpec[] = [{
|
||||
className: 'AppState',
|
||||
fields: [{ name: 'a', typeText: 'Int' }, { name: 'b', typeText: 'String' }],
|
||||
}];
|
||||
const out = render(specs, 'build-1.2.3', 'hash-abc');
|
||||
expect(out).toContain('public enum AppStateAccessor');
|
||||
expect(out).toContain('key: "a"');
|
||||
expect(out).toContain('key: "b"');
|
||||
expect(out).toContain('buildId: "build-1.2.3"');
|
||||
expect(out).toContain('accessorHash: "hash-abc"');
|
||||
expect(out).toContain('#if DEBUG');
|
||||
expect(out).toContain('#endif');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectSwiftFiles', () => {
|
||||
test('walks subdirectories and finds all .swift files sorted', () => {
|
||||
const a = join(workDir, 'a.swift');
|
||||
const sub = join(workDir, 'sub');
|
||||
mkdirSync(sub);
|
||||
const b = join(sub, 'b.swift');
|
||||
const c = join(workDir, 'c.txt');
|
||||
writeFileSync(a, 'a');
|
||||
writeFileSync(b, 'b');
|
||||
writeFileSync(c, 'c');
|
||||
const files = collectSwiftFiles(workDir);
|
||||
expect(files.sort()).toEqual([a, b].sort());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env bun
|
||||
//
|
||||
// gen-accessors (TS port). Mirrors the SwiftPM tool's logic for the cases
|
||||
// where a user doesn't want to wait 2-5min for swift-syntax to build the
|
||||
// first time. Also exercised by tests so we can verify the cache + parse
|
||||
// behavior without a Swift toolchain.
|
||||
//
|
||||
// The TS port uses a stricter regex than the fork's original — it understands:
|
||||
// - @Observable class declarations
|
||||
// - @Snapshotable property markers (only marked fields are exported)
|
||||
// - Multi-line type signatures (collapses whitespace before matching)
|
||||
// - Generic type parameters (matched as opaque text inside the type)
|
||||
//
|
||||
// What it does NOT handle (deferred to the SwiftPM tool):
|
||||
// - Computed properties with bodies (regex can mis-parse braces)
|
||||
// - Property wrappers other than @Snapshotable
|
||||
//
|
||||
// Composite cache key (codex-flagged): swift_version || tool_git_rev ||
|
||||
// platform_triple || source_content_hash. Source-only hash misses generator
|
||||
// logic changes.
|
||||
|
||||
import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from 'fs';
|
||||
import { join, resolve, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { createHash } from 'crypto';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export interface AccessorField {
|
||||
name: string;
|
||||
typeText: string;
|
||||
}
|
||||
|
||||
export interface AccessorSpec {
|
||||
className: string;
|
||||
fields: AccessorField[];
|
||||
}
|
||||
|
||||
export interface GenInputs {
|
||||
inputDir: string;
|
||||
outputDir?: string;
|
||||
buildId?: string;
|
||||
cacheRoot?: string;
|
||||
swiftVersion?: string;
|
||||
toolGitRev?: string;
|
||||
platformTriple?: string;
|
||||
}
|
||||
|
||||
export interface GenResult {
|
||||
outputPath: string;
|
||||
cacheKey: string;
|
||||
specs: AccessorSpec[];
|
||||
cacheHit: boolean;
|
||||
}
|
||||
|
||||
const FALLBACK_PLATFORM = process.platform === 'darwin' ? 'darwin-arm64' : `${process.platform}-${process.arch}`;
|
||||
|
||||
export function collectSwiftFiles(dir: string, opts: { excludeGenerated?: boolean } = {}): string[] {
|
||||
const out: string[] = [];
|
||||
const excludeGenerated = opts.excludeGenerated ?? true;
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
const s = statSync(full);
|
||||
if (s.isDirectory()) {
|
||||
// Skip generated output dir (when it lives under the input dir)
|
||||
if (excludeGenerated && name === 'DebugBridgeGenerated') continue;
|
||||
out.push(...collectSwiftFiles(full, opts));
|
||||
} else if (name.endsWith('.swift')) {
|
||||
// Skip the codegen output file. Otherwise the second run picks it up,
|
||||
// changes the cache key, and the cache never hits.
|
||||
if (excludeGenerated && name === 'StateAccessor.swift') continue;
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
export function parseSwift(source: string): AccessorSpec[] {
|
||||
const specs: AccessorSpec[] = [];
|
||||
// Find `@Observable\n(public )?(final )?class <Name>` followed by a brace
|
||||
// block. We then scan inside that block for @Snapshotable fields.
|
||||
const classPattern = /@Observable\s*(?:(?:public|internal|fileprivate|private)\s+)?(?:final\s+)?class\s+(\w+)[^{]*\{/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = classPattern.exec(source)) !== null) {
|
||||
const className = match[1]!;
|
||||
const startIdx = classPattern.lastIndex;
|
||||
const endIdx = findMatchingBrace(source, startIdx - 1);
|
||||
if (endIdx === -1) continue;
|
||||
const body = source.slice(startIdx, endIdx);
|
||||
|
||||
const fields = parseFields(body);
|
||||
if (fields.length > 0) {
|
||||
specs.push({ className, fields });
|
||||
}
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
function findMatchingBrace(s: string, openIdx: number): number {
|
||||
// openIdx points at '{'. Return idx of matching '}', or -1.
|
||||
let depth = 0;
|
||||
for (let i = openIdx; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
if (c === '{') depth++;
|
||||
else if (c === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
} else if (c === '"' || c === "'") {
|
||||
// skip string literal
|
||||
const quote = c;
|
||||
i++;
|
||||
while (i < s.length && s[i] !== quote) {
|
||||
if (s[i] === '\\') i++;
|
||||
i++;
|
||||
}
|
||||
} else if (c === '/' && s[i + 1] === '/') {
|
||||
// skip line comment
|
||||
while (i < s.length && s[i] !== '\n') i++;
|
||||
} else if (c === '/' && s[i + 1] === '*') {
|
||||
i += 2;
|
||||
while (i < s.length - 1 && !(s[i] === '*' && s[i + 1] === '/')) i++;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseFields(body: string): AccessorField[] {
|
||||
// Look for @Snapshotable followed by var/let declarations. Allow attribute
|
||||
// ordering: `@Snapshotable var name: Type` OR `@Snapshotable\n var name: Type`.
|
||||
// Multi-line types are handled by greedy non-newline match in the type, but
|
||||
// we collapse adjacent whitespace first to avoid false negatives.
|
||||
const normalized = body.replace(/[\t ]*\r?\n[\t ]*/g, ' ');
|
||||
const fieldPattern = /@Snapshotable\s+(?:(?:public|internal|fileprivate|private)\s+)?(?:var|let)\s+(\w+)\s*:\s*([^={]+?)(?=\s*(?:=|\{|@Snapshotable|\bvar\b|\blet\b|\bfunc\b|\}|$))/g;
|
||||
const fields: AccessorField[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = fieldPattern.exec(normalized)) !== null) {
|
||||
// Codex catch: skip fields that have a computed body (`{ get ... }` or
|
||||
// `{ didSet ... }` after the type). The match boundary stops before `{`,
|
||||
// so we peek at what comes after the type in the original body.
|
||||
const afterMatchIdx = m.index + m[0].length;
|
||||
const afterMatch = normalized.slice(afterMatchIdx, afterMatchIdx + 4).trim();
|
||||
// If the next non-space character is `{`, this is a computed property.
|
||||
// We're conservative: snapshot-eligible fields must be stored properties
|
||||
// (initialized with `=` or just declared).
|
||||
if (afterMatch.startsWith('{')) continue;
|
||||
fields.push({ name: m[1]!, typeText: m[2]!.trim() });
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function computeCacheKey(inputs: {
|
||||
swiftFiles: string[];
|
||||
swiftVersion: string;
|
||||
toolGitRev: string;
|
||||
platformTriple: string;
|
||||
}): string {
|
||||
const h = createHash('sha256');
|
||||
h.update(`swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|`);
|
||||
for (const f of inputs.swiftFiles) {
|
||||
const content = readFileSync(f);
|
||||
h.update(`${f}:${content.length}:`);
|
||||
h.update(content);
|
||||
h.update('|');
|
||||
}
|
||||
return h.digest('hex');
|
||||
}
|
||||
|
||||
export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string {
|
||||
let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n';
|
||||
out += '#if DEBUG\nimport Foundation\nimport DebugBridge\n\n';
|
||||
for (const spec of specs) {
|
||||
out += `@MainActor\npublic enum ${spec.className}Accessor {\n`;
|
||||
out += ` public 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 += ` )\n`;
|
||||
for (const field of spec.fields) {
|
||||
out += ` StateServer.shared.registerAccessor(\n`;
|
||||
out += ` key: "${field.name}",\n`;
|
||||
out += ` type: "${field.typeText}",\n`;
|
||||
out += ` read: { state.${field.name} as Any? },\n`;
|
||||
out += ` write: { _ in false }\n`;
|
||||
out += ` )\n`;
|
||||
}
|
||||
out += ` }\n}\n\n`;
|
||||
}
|
||||
out += '#endif\n';
|
||||
return out;
|
||||
}
|
||||
|
||||
function detectSwiftVersion(): string {
|
||||
if (process.env.SWIFT_VERSION) return process.env.SWIFT_VERSION;
|
||||
try {
|
||||
const out = execSync('swift --version', { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
||||
const m = out.match(/Apple Swift version (\d+\.\d+\.\d+)/);
|
||||
if (m) return m[1]!;
|
||||
} catch {
|
||||
/* swift not installed */
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function detectToolGitRev(): string {
|
||||
if (process.env.GEN_ACCESSORS_REV) return process.env.GEN_ACCESSORS_REV;
|
||||
try {
|
||||
return execSync('git rev-parse --short HEAD', {
|
||||
cwd: dirname(new URL(import.meta.url).pathname),
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).toString().trim();
|
||||
} catch {
|
||||
return 'dev';
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultCacheRoot(): string {
|
||||
return process.env.GSTACK_IOS_CACHE_ROOT ?? join(homedir(), '.gstack', 'cache', 'gen-accessors');
|
||||
}
|
||||
|
||||
export function generate(inputs: GenInputs): GenResult {
|
||||
const inputDir = resolve(inputs.inputDir);
|
||||
const outputDir = resolve(inputs.outputDir ?? inputDir);
|
||||
const cacheRoot = inputs.cacheRoot ?? defaultCacheRoot();
|
||||
const swiftFiles = collectSwiftFiles(inputDir);
|
||||
|
||||
const cacheKey = computeCacheKey({
|
||||
swiftFiles,
|
||||
swiftVersion: inputs.swiftVersion ?? detectSwiftVersion(),
|
||||
toolGitRev: inputs.toolGitRev ?? detectToolGitRev(),
|
||||
platformTriple: inputs.platformTriple ?? FALLBACK_PLATFORM,
|
||||
});
|
||||
|
||||
const cachedOutput = join(cacheRoot, cacheKey, 'StateAccessor.swift');
|
||||
const finalOutput = join(outputDir, 'StateAccessor.swift');
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
if (existsSync(cachedOutput)) {
|
||||
copyFileSync(cachedOutput, finalOutput);
|
||||
// Parse for return value but use cached content as truth.
|
||||
return {
|
||||
outputPath: finalOutput,
|
||||
cacheKey,
|
||||
specs: [], // intentionally empty on cache hit (no need to re-parse)
|
||||
cacheHit: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse + render fresh
|
||||
const allSpecs: AccessorSpec[] = [];
|
||||
for (const f of swiftFiles) {
|
||||
const src = readFileSync(f, 'utf-8');
|
||||
allSpecs.push(...parseSwift(src));
|
||||
}
|
||||
const rendered = render(allSpecs, inputs.buildId ?? 'unknown', cacheKey);
|
||||
writeFileSync(finalOutput, rendered);
|
||||
|
||||
// Populate cache (best-effort — cache failures don't break codegen).
|
||||
try {
|
||||
mkdirSync(join(cacheRoot, cacheKey), { recursive: true });
|
||||
writeFileSync(cachedOutput, rendered);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
return {
|
||||
outputPath: finalOutput,
|
||||
cacheKey,
|
||||
specs: allSpecs,
|
||||
cacheHit: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneCache(cacheRoot: string = defaultCacheRoot(), maxAgeDays = 30): { pruned: string[] } {
|
||||
const pruned: string[] = [];
|
||||
if (!existsSync(cacheRoot)) return { pruned };
|
||||
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
for (const name of readdirSync(cacheRoot)) {
|
||||
const full = join(cacheRoot, name);
|
||||
try {
|
||||
const s = statSync(full);
|
||||
if (s.isDirectory() && s.mtimeMs < cutoff) {
|
||||
rmSync(full, { recursive: true, force: true });
|
||||
pruned.push(full);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return { pruned };
|
||||
}
|
||||
|
||||
// CLI entry
|
||||
if (import.meta.main) {
|
||||
const args = process.argv.slice(2);
|
||||
const inputIdx = args.indexOf('--input');
|
||||
if (inputIdx === -1) {
|
||||
process.stderr.write('usage: gen-accessors --input <dir> [--output <dir>]\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const inputDir = args[inputIdx + 1]!;
|
||||
const outputIdx = args.indexOf('--output');
|
||||
const outputDir = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
|
||||
const result = generate({ inputDir, outputDir });
|
||||
process.stdout.write(
|
||||
result.cacheHit
|
||||
? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
|
||||
: `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user