mirror of https://github.com/garrytan/gstack.git
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
#!/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`,
|
|
);
|
|
}
|