diff --git a/src/domain/objcSwiftMetadata.ts b/src/domain/objcSwiftMetadata.ts new file mode 100644 index 000000000..65181a5fc --- /dev/null +++ b/src/domain/objcSwiftMetadata.ts @@ -0,0 +1,192 @@ +import { z } from "zod"; + +/** Objective-C method type. */ +export const objcMethodTypeSchema = z.enum([ + "instance", + "class", + "ivar_getter", + "ivar_setter", +]); +export type ObjcMethodType = z.infer; + +/** Objective-C property attribute. */ +export const objcPropertyAttributeSchema = z.strictObject({ + name: z.string().min(1), + value: z.string(), + is_weak: z.boolean().default(false), + is_atomic: z.boolean().default(false), + is_copy: z.boolean().default(false), + is_strong: z.boolean().default(false), +}); +export type ObjcPropertyAttribute = z.infer; + +/** Objective-C method metadata. */ +export const objcMethodSchema = z.strictObject({ + selector: z.string().min(1), + method_type: objcMethodTypeSchema, + address: z.number().int().nullable(), + is_required: z.boolean().default(false), + is_optional: z.boolean().default(false), +}); +export type ObjcMethod = z.infer; + +/** Objective-C property metadata. */ +export const objcPropertySchema = z.strictObject({ + name: z.string().min(1), + type_encoding: z.string().nullable(), + attributes: z.array(objcPropertyAttributeSchema).default([]), + is_readonly: z.boolean().default(false), + getter: z.string().nullable(), + setter: z.string().nullable(), +}); +export type ObjcProperty = z.infer; + +/** Objective-C protocol metadata. */ +export const objcProtocolSchema = z.strictObject({ + name: z.string().min(1), + methods: z.array(objcMethodSchema).default([]), + optional_methods: z.array(objcMethodSchema).default([]), + properties: z.array(objcPropertySchema).default([]), +}); +export type ObjcProtocol = z.infer; + +/** Objective-C class metadata. */ +export const objcClassSchema = z.strictObject({ + name: z.string().min(1), + super_class: z.string().nullable(), + is_meta_class: z.boolean().default(false), + is_root_class: z.boolean().default(false), + methods: z.array(objcMethodSchema).default([]), + properties: z.array(objcPropertySchema).default([]), + protocols: z.array(z.string()).default([]), + ivar_count: z.number().int().nonnegative().default(0), + instance_size: z.number().int().nonnegative().nullable(), +}); +export type ObjcClass = z.infer; + +/** Swift declaration kind. */ +export const swiftDeclKindSchema = z.enum([ + "class", + "struct", + "enum", + "protocol", + "extension", + "actor", + "global_var", + "global_func", +]); +export type SwiftDeclKind = z.infer; + +/** Swift access level. */ +export const swiftAccessLevelSchema = z.enum([ + "private", + "fileprivate", + "internal", + "public", + "open", +]); +export type SwiftAccessLevel = z.infer; + +/** Swift declaration metadata. */ +export const swiftDeclSchema = z.strictObject({ + kind: swiftDeclKindSchema, + name: z.string().min(1), + module: z.string().nullable(), + access_level: swiftAccessLevelSchema.default("internal"), + super_class: z.string().nullable(), + protocols: z.array(z.string()).default([]), + is_final: z.boolean().default(false), + is_required: z.boolean().default(false), + is_convenience_init: z.boolean().default(false), + is_override: z.boolean().default(false), +}); +export type SwiftDecl = z.infer; + +/** Database save/read operation type. */ +export const dbOperationSchema = z.enum([ + "save", + "readback", + "open_database", + "close_database", + "export", + "import", +]); +export type DbOperation = z.infer; + +/** Database save operation result. */ +export const dbSaveResultSchema = z.strictObject({ + operation: dbOperationSchema, + succeeded: z.boolean(), + preserved_names: z.number().int().nonnegative().default(0), + preserved_comments: z.number().int().nonnegative().default(0), + preserved_bookmarks: z.number().int().nonnegative().default(0), + database_path: z.string().nullable(), + error: z.string().nullable(), +}); +export type DbSaveResult = z.infer; + +/** Result of deeper ObjC/Swift metadata extraction. */ +export const objcSwiftMetadataSchema = z.strictObject({ + objc_classes: z.array(objcClassSchema).default([]), + objc_protocols: z.array(objcProtocolSchema).default([]), + swift_decls: z.array(swiftDeclSchema).default([]), + db_save_result: dbSaveResultSchema.nullable(), +}); +export type ObjcSwiftMetadata = z.infer; + +/** Check if a selector name looks like a getter. */ +export function isGetterSelector(selector: string): boolean { + return /^[a-z][a-zA-Z0-9_]*$/u.test(selector) && !selector.includes(":"); +} + +/** Check if a selector name looks like a setter. */ +export function isSetterSelector(selector: string): boolean { + return selector.startsWith("set") && selector.endsWith(":"); +} + +/** Extract property name from a getter/setter selector. */ +export function propertyNameFromSelector(selector: string): string | null { + if (isSetterSelector(selector)) { + const inner = selector.slice(3, -1); + return inner.charAt(0).toLowerCase() + inner.slice(1); + } + if (isGetterSelector(selector)) { + return selector; + } + return null; +} + +/** Count ObjC methods by type. */ +export function countMethodsByType( + methods: readonly ObjcMethod[], +): Record { + return { + instance: methods.filter((m) => m.method_type === "instance").length, + class: methods.filter((m) => m.method_type === "class").length, + ivar_getter: methods.filter((m) => m.method_type === "ivar_getter").length, + ivar_setter: methods.filter((m) => m.method_type === "ivar_setter").length, + }; +} + +/** Get all Swift declarations of a specific kind. */ +export function swiftDeclsByKind( + decls: readonly SwiftDecl[], + kind: SwiftDeclKind, +): SwiftDecl[] { + return decls.filter((d) => d.kind === kind); +} + +/** Check if a database save preserved all expected items. */ +export function isDbSaveComplete( + result: DbSaveResult, + expectedNames: number, + expectedComments: number, + expectedBookmarks: number, +): boolean { + return ( + result.succeeded && + result.preserved_names >= expectedNames && + result.preserved_comments >= expectedComments && + result.preserved_bookmarks >= expectedBookmarks + ); +} diff --git a/tests/objcSwiftMetadata.test.ts b/tests/objcSwiftMetadata.test.ts new file mode 100644 index 000000000..a8f876faf --- /dev/null +++ b/tests/objcSwiftMetadata.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; + +import { + countMethodsByType, + isDbSaveComplete, + isGetterSelector, + isSetterSelector, + objcSwiftMetadataSchema, + propertyNameFromSelector, + swiftDeclsByKind, + type ObjcClass, + type ObjcMethod, + type ObjcProperty, + type DbOperation, + type ObjcSwiftMetadata, + type ObjcProtocol, + type ObjcPropertyAttribute, + type SwiftDecl, + type SwiftAccessLevel, + type DbSaveResult, +} from "../src/domain/objcSwiftMetadata.js"; + +describe("ObjC/Swift metadata", () => { + it("identifies getter selectors", () => { + expect(isGetterSelector("name")).toBe(true); + expect(isGetterSelector("setName:")).toBe(false); + expect(isGetterSelector("set")).toBe(true); + }); + + it("identifies setter selectors", () => { + expect(isSetterSelector("setName:")).toBe(true); + expect(isSetterSelector("name")).toBe(false); + }); + + it("extracts property name from selector", () => { + expect(propertyNameFromSelector("name")).toBe("name"); + expect(propertyNameFromSelector("setName:")).toBe("name"); + expect(propertyNameFromSelector("set")).toBe("set"); + }); + + it("counts methods by type", () => { + const methods: ObjcMethod[] = [ + { + selector: "init", + method_type: "instance", + address: 0x1000, + is_required: false, + is_optional: false, + }, + { + selector: "alloc", + method_type: "class", + address: 0x2000, + is_required: false, + is_optional: false, + }, + ]; + const counts = countMethodsByType(methods); + expect(counts.instance).toBe(1); + expect(counts.class).toBe(1); + expect(counts.ivar_getter).toBe(0); + expect(counts.ivar_setter).toBe(0); + }); + + it("filters Swift declarations by kind", () => { + const decls: SwiftDecl[] = [ + { + kind: "class", + name: "Foo", + module: null, + access_level: "public", + super_class: null, + protocols: [], + is_final: false, + is_required: false, + is_convenience_init: false, + is_override: false, + }, + ]; + const classes = swiftDeclsByKind(decls, "class"); + expect(classes).toHaveLength(1); + expect(classes[0]!.name).toBe("Foo"); + }); + + it("checks database save completeness", () => { + const result: DbSaveResult = { + operation: "save", + succeeded: true, + preserved_names: 10, + preserved_comments: 5, + preserved_bookmarks: 3, + database_path: "/tmp/db.hop", + error: null, + }; + expect(isDbSaveComplete(result, 10, 5, 3)).toBe(true); + expect(isDbSaveComplete(result, 11, 5, 3)).toBe(false); + }); + + it("validates a well-formed metadata result", () => { + const meta = { + objc_classes: [] as ObjcClass[], + objc_protocols: [] as ObjcProtocol[], + swift_decls: [] as SwiftDecl[], + db_save_result: null, + }; + const result = objcSwiftMetadataSchema.safeParse(meta); + expect(result.success).toBe(true); + }); + + it("uses all exported types", () => { + const prop: ObjcProperty = { + name: "title", + type_encoding: 'T@"NSString"&,N,V_title', + attributes: [] as ObjcPropertyAttribute[], + is_readonly: false, + getter: "title", + setter: "setTitle:", + }; + expect(prop.name).toBe("title"); + const op: DbOperation = "save"; + expect(op).toBe("save"); + const meta: ObjcSwiftMetadata = { + objc_classes: [], + objc_protocols: [], + swift_decls: [], + db_save_result: null, + }; + expect(meta.objc_classes).toHaveLength(0); + }); +});