1
0
Fork 0

Bind the GIO async pattern as Swift async methods

This commit is contained in:
Brendan Szymanski 2026-08-11 23:47:16 -04:00
parent 2d75fa9d86
commit 298e5a4b43
19 changed files with 722 additions and 1398 deletions

View file

@ -0,0 +1,146 @@
// AsyncCallableTests.swift
// Covers the GIO async pattern binding: `planAsyncPairs` recognizing a
// `*_async`/`*_finish` pair and `renderAsyncMethod` emitting the paired
// Swift `async` method. Mirrors `CallbackGenerationTests.swift`'s style
// exercising the planner through its public entry points and the renderer
// through `renderModule`'s public file output.
import Testing
@testable import GObjectGeneratorCore
@Suite("GIO async pattern")
struct AsyncCallableTests {
// MARK: - Fixture
/// A minimal `Gio`-module fixture: an `AsyncResult` class, a `Widget`
/// class with one properly-shaped `foo_async`/`foo_finish` pair, and
/// the `Gio.AsyncReadyCallback` callback type the pair's starter uses.
/// Modeled entirely inside "Gio" itself (rather than a separate
/// dependent module) so rule 6 (`currentModule == "Gio"`) is satisfied
/// trivially, keeping the fixture minimal.
func makeFooAsyncClass(includeFinish: Bool = true) -> Class {
var methods = [
Method(
name: "foo_async", cIdentifier: "g_widget_foo_async",
parameters: [
Parameter(name: "self", type: .typeRef("Widget"), isInstanceParameter: true),
Parameter(name: "callback", type: .typeRef("AsyncReadyCallback", namespace: "Gio"),
cType: "GAsyncReadyCallback", isNullable: true),
Parameter(name: "user_data", type: .pointer, cType: "gpointer", isNullable: true),
],
returnValue: ReturnValue()
),
]
if includeFinish {
methods.append(Method(
name: "foo_finish", cIdentifier: "g_widget_foo_finish",
parameters: [
Parameter(name: "self", type: .typeRef("Widget"), isInstanceParameter: true),
Parameter(name: "result", type: .typeRef("AsyncResult", namespace: "Gio"), cType: "GAsyncResult*"),
],
returnValue: ReturnValue(type: .boolean),
throwsGError: true
))
}
return Class(name: "Widget", cType: "GWidget", parent: nil,
getTypeFunction: "g_widget_get_type", methods: methods)
}
func makeContext(includeFinish: Bool = true) -> (context: MapContext, klass: Class) {
let klass = makeFooAsyncClass(includeFinish: includeFinish)
let gio = Repository(namespaces: [
Namespace(
name: "Gio", version: "2.0",
classes: [
Class(name: "AsyncResult", cType: "GAsyncResult", parent: nil,
getTypeFunction: "g_async_result_get_type"),
klass,
],
callbacks: [Callback(
name: "AsyncReadyCallback", cType: "GAsyncReadyCallback",
parameters: [
Parameter(name: "source_object", type: .pointer, cType: "gpointer", isNullable: true),
Parameter(name: "res", type: .typeRef("AsyncResult"), cType: "GAsyncResult*"),
Parameter(name: "data", type: .pointer, cType: "gpointer", isNullable: true),
]
)]
)
])
let registry = TypeRegistry(repositories: ["Gio": gio])
let context = MapContext(registry: registry, currentModule: "Gio", currentNamespace: "Gio")
return (context, klass)
}
// MARK: - Recognizer
@Test("A parsed foo_async + foo_finish pair produces one AsyncCallablePlan, and the starter's asyncRole parameters are the last two")
func fooAsyncFooFinishProducesOnePair() throws {
let (context, klass) = makeContext()
let (plan, memberSkips) = planClass(klass, context: context)
#expect(plan.asyncMethods.count == 1)
let pair = try #require(plan.asyncMethods.first)
#expect(pair.starter.name == "fooAsync")
#expect(pair.finish.name == "fooFinish")
// The starter's Swift-facing signature carries no callback/user-data
// parameters only the async-role pair, trailing.
let realParams = pair.starter.parameters.filter { !$0.isInstanceParameter }
#expect(realParams.count == 2)
#expect(realParams[0].asyncRole == .callback)
#expect(realParams[1].asyncRole == .userData)
// No callable reaches methodPlans still skipped for the callback param.
#expect(!plan.methods.contains { $0.name == "fooAsync" })
#expect(plan.methods.contains { $0.name == "fooFinish" })
// The stale D4.3 skip for the paired starter was dropped.
#expect(!memberSkips.contains { $0.cIdentifier == "g_widget_foo_async" })
}
@Test("foo_async with no foo_finish produces no pair and keeps its callbackWithoutUserData skip")
func fooAsyncWithoutFinishStaysSkipped() throws {
let (context, klass) = makeContext(includeFinish: false)
let (plan, memberSkips) = planClass(klass, context: context)
#expect(plan.asyncMethods.isEmpty)
#expect(!plan.methods.contains { $0.name == "fooAsync" })
let skip = try #require(memberSkips.first { $0.cIdentifier == "g_widget_foo_async" })
#expect(skip.reason == .callbackWithoutUserData)
}
// MARK: - Renderer
@Test("renderAsyncMethod output contains async throws, _sgtkAwaitAsyncReady, defer g_object_unref, and AsyncResultRef(retaining:)")
func renderedAsyncMethodContainsExpectedFragments() throws {
let (context, klass) = makeContext()
let (plan, _) = planClass(klass, context: context)
let module = ModulePlan(module: "Gio", types: [.class(plan)], skips: [], coverage: CoverageStats())
let files = renderModule(module)
let source = try #require(files["Widget.swift"])
#expect(source.contains("public func fooAsync(") )
#expect(source.contains(") async throws"))
#expect(source.contains("_sgtkAwaitAsyncReady"))
#expect(source.contains("defer { g_object_unref("))
#expect(source.contains("AsyncResultRef(retaining:"))
#expect(source.contains("try fooFinish(result:"))
}
@Test("swiftSignature for a starter omits the callback and user-data parameters")
func starterSignatureOmitsAsyncRoleParameters() throws {
let (context, klass) = makeContext()
let (plan, _) = planClass(klass, context: context)
let module = ModulePlan(module: "Gio", types: [.class(plan)], skips: [], coverage: CoverageStats())
let files = renderModule(module)
let source = try #require(files["Widget.swift"])
// The starter takes no real parameters beyond the trailing async
// pair, so its emitted signature is empty parens.
#expect(source.contains("public func fooAsync() async throws"))
#expect(!source.contains("callback:"))
#expect(!source.contains("userData:"))
}
}

View file

@ -123,6 +123,43 @@ struct CallbackGenerationTests {
#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 `<callback>` spells its
// `res` parameter as a bare `<type name="AsyncResult"/>` (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

View file

@ -573,6 +573,37 @@ struct TypeMapperTests {
}
}
@Test("A gconstpointer-typed scalar buffer (no elementCType) maps to [UInt8]")
func arrayParamGconstpointerBuffer() throws {
// Mirrors g_bytes_new's `data` parameter:
// <array length="1" zero-terminated="0" c:type="gconstpointer"><type name="guint8"/></array>
// the array's own c:type is the pointer TYPEDEF `gconstpointer`
// (no literal `*`), and the element has no c:type of its own.
let m = try mapArrayParameter(
element: .uint8,
info: ArrayInfo(lengthParameterIndex: 1, cType: "gconstpointer"),
transfer: .none, context: makeContext())
#expect(m.swiftType == "[UInt8]")
#expect(m.marshalIn == .scalarArrayToC)
}
@Test("A transfer=full gconstpointer-typed scalar buffer is rejected (the helper only lends a temporary copy)")
func arrayParamGconstpointerBufferTransferFullRejected() {
// Mirrors g_bytes_new_take's `data` parameter, transfer-ownership="full":
// the C callee frees the buffer, but `_withScalarArray` only lends a
// pointer into a temporary Swift copy for the call's duration.
do {
_ = try mapArrayParameter(
element: .uint8,
info: ArrayInfo(lengthParameterIndex: 1, cType: "gconstpointer"),
transfer: .full, context: makeContext())
Issue.record("Expected transfer=full scalar buffer to be rejected")
} catch {
#expect(error.reason == .arrayBridgingUnimplemented)
#expect(error.detail.contains("transfer-ownership=full"))
}
}
@Test("A zero-terminated scalar buffer with no length param is rejected")
func arrayParamScalarWithoutLengthRejected() {
do {