// CallbackGenerationTests.swift // Covers Phase D6/D4.3: namespace-level callback typealias emission and // callback-typed parameter marshalling. // // D4.3 status: the planner (`Planner.planParameters`) still blanket-skips // every callback-typed parameter with `.callbackWithoutUserData` — real // binding is blocked by a Swift compiler ICE ("failed to produce diagnostic // for expression") triggered when a `@convention(c)` closure boxed in the // generic `_ClosureBox` is passed to a C function expecting a distinct // `@convention(c)` typealias (reproduced on `g_qsort_with_data`). See // `Planner.swift`'s D4.3 comment and HANDOFF.md. The renderer-side // infrastructure (`marshalCallArg`'s `.callbackBox` branch, `callbackBoxSetup` // / `callbackBoxRelease`) is implemented and exercised here by constructing // `CallablePlan` values directly — bypassing the planner — so the renderer // logic is proven correct independent of the planner's current skip. import Testing @testable import GObjectGeneratorCore @Suite("Callback generation") struct CallbackGenerationTests { func makeContext() -> MapContext { let glib = Repository(namespaces: [ Namespace(name: "GLib", version: "2.0", callbacks: [Callback(name: "CompareDataFunc", cType: "GCompareDataFunc")]) ]) let registry = TypeRegistry(repositories: ["GLib": glib]) return MapContext(registry: registry, currentModule: "GLib", currentNamespace: "GLib") } // MARK: - Typealias emission (D6) @Test("renderCallbackType emits both the @convention(c) form and the Swift-closure form") func callbackTypeEmitsBothTypealiases() throws { let plan = CallbackTypePlan( name: "CompareFunc", swiftType: "(UnsafeRawPointer?, UnsafeRawPointer?) -> Int32", cSwiftType: "@convention(c) (UnsafeRawPointer?, UnsafeRawPointer?) -> Int32" ) let module = ModulePlan(module: "GLib", types: [.callback(plan)], skips: [], coverage: CoverageStats()) let files = renderModule(module) let source = files["Callbacks.swift"] ?? "" #expect(source.contains("@_spi(SGTKInternal) public typealias CompareFunc = @convention(c) (UnsafeRawPointer?, UnsafeRawPointer?) -> Int32")) // The Swift-closure form still carries raw UnsafeRawPointer args here, // so it is hidden behind @_spi too (Phase E4 gpointer policy). #expect(source.contains("@_spi(SGTKInternal) public typealias CompareFuncSwift = (UnsafeRawPointer?, UnsafeRawPointer?) -> Int32")) } @Test("A pointer-free Swift-closure form stays plain public; only the @convention(c) form is @_spi") func callbackTypePointerFreeSwiftFormStaysPublic() throws { let plan = CallbackTypePlan( name: "NotifyFunc", swiftType: "(String) -> Void", cSwiftType: "@convention(c) (UnsafeMutableRawPointer?) -> Void" ) let module = ModulePlan(module: "GLib", types: [.callback(plan)], skips: [], coverage: CoverageStats()) let files = renderModule(module) let source = files["Callbacks.swift"] ?? "" #expect(source.contains("@_spi(SGTKInternal) public typealias NotifyFunc = @convention(c) (UnsafeMutableRawPointer?) -> Void")) #expect(source.contains("public typealias NotifyFuncSwift = (String) -> Void")) #expect(!source.contains("@_spi(SGTKInternal) public typealias NotifyFuncSwift")) } // MARK: - Planner baseline (unchanged pending D4.3) @Test("A callback-typed parameter still skips with callbackWithoutUserData (D4.3 deferred)") func callbackParamStillSkipped() throws { let fn = GlobalFunction( name: "qsort_with_data", cIdentifier: "g_qsort_with_data", parameters: [ Parameter(name: "compare_func", type: .typeRef("CompareDataFunc", namespace: "GLib"), cType: "GCompareDataFunc"), ] ) guard case .skip(let entry) = planFunction(fn, context: makeContext()) else { Issue.record("expected callback-typed param to still skip pending D4.3"); return } #expect(entry.reason == .callbackWithoutUserData) } // MARK: - Parameter/return nullability (review finding #2) @Test("Callback param and return nullability propagate into the mapped closure type") func callbackNullabilityPropagates() throws { let glib = Repository(namespaces: [ Namespace(name: "GLib", version: "2.0", callbacks: [Callback( name: "NullableFunc", cType: "GNullableFunc", parameters: [ Parameter(name: "value", type: .string, cType: "const char*", isNullable: true), ], returnValue: ReturnValue(type: .string, isNullable: true) )]) ]) let registry = TypeRegistry(repositories: ["GLib": glib]) let ctx = MapContext(registry: registry, currentModule: "GLib", currentNamespace: "GLib") let mapping = try map(.typeRef("NullableFunc", namespace: "GLib"), nullable: false, transfer: .none, context: ctx) #expect(mapping.swiftType.contains("String?")) #expect(mapping.swiftType.hasSuffix("-> String?")) } @Test("A non-nullable callback param/return stays non-optional") func callbackNonNullableStaysNonOptional() throws { let glib = Repository(namespaces: [ Namespace(name: "GLib", version: "2.0", callbacks: [Callback( name: "PlainFunc", cType: "GPlainFunc", parameters: [ Parameter(name: "value", type: .string, cType: "const char*", isNullable: false), ], returnValue: ReturnValue(type: .string, isNullable: false) )]) ]) let registry = TypeRegistry(repositories: ["GLib": glib]) let ctx = MapContext(registry: registry, currentModule: "GLib", currentNamespace: "GLib") let mapping = try map(.typeRef("PlainFunc", namespace: "GLib"), nullable: false, transfer: .none, context: ctx) #expect(!mapping.swiftType.contains("String?")) #expect(mapping.swiftType.hasSuffix("-> String")) } // MARK: - Declaring-namespace resolution (GIO async pattern, Step 1) @Test("A callback declared in namespace A whose parameter is a bare type in A, referenced from namespace B, maps successfully") func callbackResolvesOwnParametersAgainstDeclaringNamespace() throws { // Mirrors `Gio.AsyncReadyCallback`: Gio's own `` spells its // `res` parameter as a bare `` (no // namespace prefix), but Gdk references the callback type itself as // `Gio.AsyncReadyCallback`. Resolving that bare `AsyncResult` against // the CALLER's namespace ("B" here, "Gdk" for real) instead of the // callback's OWN declaring namespace ("A" / "Gio") is exactly the bug // Step 1 fixes. let namespaceA = Repository(namespaces: [ Namespace( name: "A", version: "1.0", enumerations: [ Enumeration(name: "Status", cType: "AStatus", getTypeFunction: "a_status_get_type"), ], callbacks: [Callback( name: "ReadyCallback", cType: "AReadyCallback", parameters: [ Parameter(name: "status", type: .typeRef("Status"), cType: "AStatus"), ] )] ) ]) let namespaceB = Repository( namespaces: [Namespace(name: "B", version: "1.0")], includedPackages: [IncludeEntry(name: "A", version: "1.0")] ) let registry = TypeRegistry(repositories: ["A": namespaceA, "B": namespaceB]) let ctx = MapContext(registry: registry, currentModule: "B", currentNamespace: "B", dependencyModules: ["A"]) let mapping = try map(.typeRef("ReadyCallback", namespace: "A"), nullable: false, transfer: .none, context: ctx) #expect(mapping.category == .callback) #expect(mapping.swiftType.contains("Status")) } // MARK: - Renderer infrastructure (constructed directly — D4.3 render-side proof) /// A `.callbackBox(scope: .call, …)` parameter that doubles as its own /// user-data slot (`closureIndex == cArgIndex`, GLib's common /// `..._with_data` pattern) — mirrors the shape `g_qsort_with_data` would /// plan to once D4.3's planner-side ICE is resolved. var scopeCallCallable: CallablePlan { let callbackMapping = Mapping( swiftType: "(UnsafeRawPointer?, UnsafeRawPointer?, UnsafeMutableRawPointer?) -> Int32", cSwiftType: "@convention(c) (UnsafeRawPointer?, UnsafeRawPointer?, UnsafeMutableRawPointer?) -> Int32", marshalIn: .callbackBox(scope: .call, destroyTrampoline: ""), marshalOut: .direct, category: .callback ) let param = ParameterPlan( swiftName: "compareFunc", cArgIndex: 0, mapping: callbackMapping, closureIndex: 0 ) return CallablePlan(name: "qsortWithData", cIdentifier: "g_qsort_with_data", parameters: [param]) } @Test("A scope=.call callback param has no separate user-data ParameterPlan when closureIndex == cArgIndex") func scopeCallHasNoSeparateUserData() throws { let plan = scopeCallCallable #expect(plan.parameters.count == 1) let param = plan.parameters[0] guard case .callbackBox(let scope, let destroyTrampoline) = param.mapping.marshalIn else { Issue.record("expected .callbackBox marshalIn"); return } #expect(scope == .call) #expect(destroyTrampoline == "") #expect(param.closureIndex == param.cArgIndex) } @Test("renderCallable for a scope=.call callback emits box setup before the C call and release after") func scopeCallEmitsSetupAndRelease() throws { let module = ModulePlan(module: "GLib", types: [.callable(scopeCallCallable)], skips: [], coverage: CoverageStats()) let files = renderModule(module) let source = files["Functions.swift"] ?? "" // Setup: box the closure and take an opaque retained pointer BEFORE // the C call. #expect(source.contains("_ClosureBox(compareFunc)")) #expect(source.contains("Unmanaged.passRetained")) #expect(source.contains("g_qsort_with_data(")) // Release: scope=.call takes the closure back and releases it AFTER // the C call returns (matches gtk-rs stack-borrow lifetime) — the box // must not leak. #expect(source.contains("takeRetainedValue()")) // Ordering: the setup line must appear before the C call line, and // the release line after it. let setupIdx = source.range(of: "Unmanaged.passRetained")!.lowerBound let callIdx = source.range(of: "g_qsort_with_data(")!.lowerBound let releaseIdx = source.range(of: "takeRetainedValue()")!.lowerBound #expect(setupIdx < callIdx) #expect(callIdx < releaseIdx) } @Test("Support.swift emits the closure-box runtime when a module has callback-param callables but no signals") func supportEmitsClosureBoxForCallbacksWithoutSignals() throws { let module = ModulePlan(module: "GLib", types: [.callable(scopeCallCallable)], skips: [], coverage: CoverageStats()) let files = renderModule(module) let support = files["Support.swift"] ?? "" #expect(support.contains("_ClosureBox")) } // MARK: - CoverageStats (R7 / D8) @Test("CoverageStats tallies bound callbacks and signals from a fixture namespace") func coverageStatsCountsCallbacksAndSignals() throws { let ns = Namespace( name: "GLib", version: "2.0", classes: [ Class(name: "Emitter", cType: "GEmitter", parent: nil, getTypeFunction: "g_emitter_get_type", signals: [Signal(name: "fired", isDetailed: false)]), ], callbacks: [ Callback(name: "SimpleCallback", cType: "GSimpleCallback"), ] ) let repo = Repository(namespaces: [ns]) let analysis = MultiPackageAnalysis( repositories: ["GLib": repo], directDependencies: ["GLib": []], transitiveDependencies: ["GLib": []], implicitImports: ["GLib": []], packageConfigs: [:] ) let registry = TypeRegistry(repositories: ["GLib": repo]) let module = planModules(analysis: analysis, registry: registry)["GLib"] let coverage = module?.coverage ?? CoverageStats() #expect(coverage.boundCallbacks == 1) #expect(coverage.totalCallbacks == 1) #expect(coverage.boundSignals == 1) #expect(coverage.totalSignals == 1) } }