1
0
Fork 0
gobject-generator/Tests/GObjectGeneratorCoreTests/CallbackGenerationTests.swift

227 lines
11 KiB
Swift

// 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<T>` 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: - 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)
}
}