Generate boxed-record memory management and interface requirements
Boxed records (C4): resolve each record's copy/free function from the GIR copy-function/free-function attribute, else its own ref/copy and unref/free method, and render init(retaining:) plus an isolated deinit. The deinit is isolated because the package's default MainActor isolation makes accessing the non-Sendable pointer from a nonisolated deinit a hard error. The registry uses the same resolution so transfer=none returns copy instead of adopting a borrowed pointer. This supersedes the earlier deferral: the hypothesised OpaquePointer/Sendable barriers either did not hold or were solvable. Interfaces (C5): plan each interface method through planMethod and render it as a protocol requirement (no body); the conforming class supplies the C call.
This commit is contained in:
parent
8698d48e1c
commit
164f8b7b9a
7 changed files with 320 additions and 14 deletions
|
|
@ -443,12 +443,17 @@ public struct InterfacePlan: Equatable, Sendable {
|
|||
public let cType: String
|
||||
public let prereqs: [String]
|
||||
public let getTypeFunction: String?
|
||||
/// The interface's instance methods, rendered as protocol requirements.
|
||||
/// The implementing class supplies each method body (the C symbol lives on
|
||||
/// the class, not the interface — see `planInterface`).
|
||||
public let methods: [CallablePlan]
|
||||
public let doc: String?
|
||||
|
||||
public init(name: String, cType: String, prereqs: [String] = [],
|
||||
getTypeFunction: String? = nil, doc: String? = nil) {
|
||||
getTypeFunction: String? = nil, methods: [CallablePlan] = [],
|
||||
doc: String? = nil) {
|
||||
self.name = name; self.cType = cType; self.prereqs = prereqs
|
||||
self.getTypeFunction = getTypeFunction; self.doc = doc
|
||||
self.getTypeFunction = getTypeFunction; self.methods = methods; self.doc = doc
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -224,13 +224,38 @@ private func renderRecord(_ plan: RecordPlan) -> String {
|
|||
if let doc = plan.doc {
|
||||
lines.append(contentsOf: renderDocComment(doc))
|
||||
}
|
||||
|
||||
lines.append("public final class \(plan.name) {")
|
||||
lines.append(" public let pointer: UnsafeMutableRawPointer")
|
||||
lines.append("")
|
||||
lines.append(" /// Adopts an owned boxed pointer; the wrapper takes responsibility")
|
||||
lines.append(" /// for freeing it. Use for `transfer-ownership=\"full\"` returns.")
|
||||
lines.append(" public init(takingOwnership pointer: UnsafeMutableRawPointer) {")
|
||||
lines.append(" self.pointer = pointer")
|
||||
lines.append(" }")
|
||||
|
||||
// init(retaining:) — copies a borrowed pointer so the wrapper owns its own
|
||||
// instance. Only emitted when a copy/ref function is known.
|
||||
if let copy = plan.copyFunction {
|
||||
lines.append("")
|
||||
lines.append(" /// Copies a borrowed boxed pointer so the wrapper owns an independent")
|
||||
lines.append(" /// instance. Use for `transfer-ownership=\"none\"` returns.")
|
||||
lines.append(" public init(retaining pointer: UnsafeMutableRawPointer) {")
|
||||
lines.append(" self.pointer = _rawPointer(\(copy)(_instancePointer(pointer)))")
|
||||
lines.append(" }")
|
||||
}
|
||||
|
||||
// deinit — frees the owned pointer. `isolated` runs the deinit on the
|
||||
// type's default actor (MainActor for this package), which is required to
|
||||
// touch the non-Sendable `pointer` from a deinitialiser under strict
|
||||
// concurrency. Only emitted when a free/unref function is known; without
|
||||
// one the wrapper leaks rather than risk freeing with the wrong function.
|
||||
if let free = plan.freeFunction {
|
||||
lines.append("")
|
||||
lines.append(" isolated deinit {")
|
||||
lines.append(" \(free)(_instancePointer(pointer))")
|
||||
lines.append(" }")
|
||||
}
|
||||
|
||||
lines.append("}")
|
||||
return lines.joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
|
@ -251,10 +276,25 @@ private func renderInterface(_ plan: InterfacePlan) -> String {
|
|||
|
||||
lines.append("public protocol \(plan.name)\(protocolInherits) {")
|
||||
lines.append(" var pointer: UnsafeMutableRawPointer { get }")
|
||||
for method in plan.methods {
|
||||
lines.append(contentsOf: renderProtocolRequirement(method))
|
||||
}
|
||||
lines.append("}")
|
||||
return lines.joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
||||
/// Renders one interface method as a protocol requirement: the `func`
|
||||
/// signature only — no `public`, no body. The implementing class provides the
|
||||
/// body via its own generated method (the C symbol lives on the class).
|
||||
private func renderProtocolRequirement(_ plan: CallablePlan) -> [String] {
|
||||
var lines: [String] = []
|
||||
if let doc = plan.doc { lines.append(contentsOf: renderDocComment(doc).map { " \($0)" }) }
|
||||
let retType = callableReturnType(plan) ?? ""
|
||||
let throwsKeyword = plan.throwsError ? " throws" : ""
|
||||
lines.append(" func \(plan.name)(\(swiftSignature(plan)))\(throwsKeyword)\(retType)")
|
||||
return lines
|
||||
}
|
||||
|
||||
// ── Class ──
|
||||
|
||||
private func renderClass(_ plan: ClassPlan) -> String {
|
||||
|
|
|
|||
|
|
@ -194,7 +194,9 @@ private func skipInterface(into skips: inout [SkipEntry], into types: inout [Typ
|
|||
reason: .notIntrospectable, detail: "non-introspectable interface"))
|
||||
return 0
|
||||
}
|
||||
types.append(.interface(planInterface(iface, context: context)))
|
||||
let (plan, memberSkips) = planInterface(iface, context: context)
|
||||
skips.append(contentsOf: memberSkips)
|
||||
types.append(.interface(plan))
|
||||
return 1
|
||||
}
|
||||
|
||||
|
|
@ -287,35 +289,102 @@ let reservedSwiftTypes: Set<String> = [
|
|||
"Object", "Type", "Protocol",
|
||||
]
|
||||
|
||||
/// Plans a boxed record as an opaque pointer wrapper.
|
||||
/// Plans a boxed record as an opaque pointer wrapper, resolving the copy and
|
||||
/// free functions that give it correct memory management (C4).
|
||||
func planRecord(_ record: Record, context: MapContext) -> RecordPlan {
|
||||
return RecordPlan(
|
||||
name: record.name, cType: record.cType,
|
||||
getTypeFunction: record.getTypeFunction,
|
||||
copyFunction: record.copyFunction,
|
||||
freeFunction: record.freeFunction,
|
||||
copyFunction: resolvedCopyFunction(record),
|
||||
freeFunction: resolvedFreeFunction(record),
|
||||
doc: record.doc
|
||||
)
|
||||
}
|
||||
|
||||
/// Plans an interface as a Swift protocol stub.
|
||||
func planInterface(_ iface: Interface, context: MapContext) -> InterfacePlan {
|
||||
/// Resolves a boxed record's destructor C identifier.
|
||||
///
|
||||
/// Prefers the explicit GIR `free-function` attribute; otherwise falls back to
|
||||
/// the record's own parameterless `unref` (preferred, ref-counted) or `free`
|
||||
/// method. `g_boxed_free` is deliberately *not* used as a fallback: it lives in
|
||||
/// GObject, which the GLib module cannot link against (GObject depends on GLib,
|
||||
/// not the reverse). Returns `nil` when no safe destructor exists — the wrapper
|
||||
/// then renders without a `deinit` (documented leak) rather than risk a wrong
|
||||
/// free.
|
||||
func resolvedFreeFunction(_ record: Record) -> String? {
|
||||
if let explicit = record.freeFunction { return explicit }
|
||||
return record.instanceReleaseMethod(named: ["unref", "free"])
|
||||
}
|
||||
|
||||
/// Resolves a boxed record's copy/ref C identifier.
|
||||
///
|
||||
/// Prefers the explicit GIR `copy-function` attribute; otherwise falls back to
|
||||
/// the record's own parameterless `ref` (preferred, ref-counted) or `copy`
|
||||
/// method. Returns `nil` when none exists — the wrapper then renders without
|
||||
/// `init(retaining:)`, and borrowed (`transfer-ownership="none"`) returns of
|
||||
/// this type must not be adopted as owned.
|
||||
func resolvedCopyFunction(_ record: Record) -> String? {
|
||||
if let explicit = record.copyFunction { return explicit }
|
||||
return record.instanceReleaseMethod(named: ["ref", "copy"])
|
||||
}
|
||||
|
||||
extension Record {
|
||||
/// The C identifier of the first bindable instance method matching one of
|
||||
/// `names` (in priority order) that takes no arguments beyond the instance
|
||||
/// itself — the shape every canonical `ref`/`unref`/`copy`/`free` shares.
|
||||
func instanceReleaseMethod(named names: [String]) -> String? {
|
||||
for wanted in names {
|
||||
if let method = methods.first(where: {
|
||||
$0.name == wanted && $0.symbolInfo.isBindable
|
||||
&& $0.parameters.allSatisfy(\.isInstanceParameter)
|
||||
}) {
|
||||
return method.cIdentifier
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Plans an interface as a Swift protocol, planning its instance methods as
|
||||
/// protocol requirements.
|
||||
///
|
||||
/// A GObject interface method is dispatched through the implementing class's
|
||||
/// instance pointer — the concrete C symbol (e.g. `gtk_widget_get_buildable_id`)
|
||||
/// lives on the class, not the interface. So each interface method becomes a
|
||||
/// protocol *requirement* only; the implementing class supplies the body via
|
||||
/// its own `planClass` methods. Methods that cannot be planned (unsupported
|
||||
/// types, varargs, …) are returned as skip entries, mirroring `planClass`.
|
||||
///
|
||||
/// - Returns: The interface plan and the skip entries for its unplannable
|
||||
/// methods.
|
||||
func planInterface(_ iface: Interface, context: MapContext) -> (plan: InterfacePlan, memberSkips: [SkipEntry]) {
|
||||
let registry = context.registry
|
||||
let girName = "\(context.currentNamespace).\(iface.name)"
|
||||
|
||||
|
||||
// Resolve prerequisite types through registry
|
||||
let prereqSwiftNames: [String] = iface.prereqs.compactMap { prereq in
|
||||
let qualified = prereq.contains(".") ? prereq : "\(context.currentNamespace).\(prereq)"
|
||||
guard let resolved = registry.resolve(girName: qualified) else { return nil }
|
||||
return registry.swiftTypeName(for: resolved, in: context.currentModule)
|
||||
}
|
||||
|
||||
return InterfacePlan(
|
||||
|
||||
// Instance methods become protocol requirements. Unplannable ones are
|
||||
// recorded as skips, matching class member behaviour.
|
||||
var memberSkips: [SkipEntry] = []
|
||||
var methodPlans: [CallablePlan] = []
|
||||
for method in iface.methods where method.symbolInfo.isBindable {
|
||||
switch planMethod(method, context: context) {
|
||||
case .success(let plan): methodPlans.append(plan)
|
||||
case .skip(let entry): memberSkips.append(entry)
|
||||
}
|
||||
}
|
||||
|
||||
let plan = InterfacePlan(
|
||||
name: iface.name, cType: iface.cType,
|
||||
prereqs: prereqSwiftNames,
|
||||
getTypeFunction: iface.getTypeFunction,
|
||||
methods: methodPlans,
|
||||
doc: iface.doc
|
||||
)
|
||||
return (plan, memberSkips)
|
||||
}
|
||||
|
||||
/// Plans a class as a GObject wrapper with cross-module inheritance, planning
|
||||
|
|
|
|||
|
|
@ -227,7 +227,11 @@ public struct TypeRegistry: Sendable {
|
|||
if let forType = rec.isGTypeStructFor {
|
||||
category = .gtypeStruct(forType: qualify(forType))
|
||||
} else if rec.isBoxed {
|
||||
category = .boxedRecord(copyFunction: rec.copyFunction, freeFunction: rec.freeFunction)
|
||||
// Resolve copy/free the same way the planner does (explicit GIR
|
||||
// attribute, else the record's own ref/copy/unref/free method)
|
||||
// so borrowed (transfer="none") returns copy correctly instead
|
||||
// of adopting a pointer they don't own.
|
||||
category = .boxedRecord(copyFunction: resolvedCopyFunction(rec), freeFunction: resolvedFreeFunction(rec))
|
||||
} else {
|
||||
category = .plainRecord
|
||||
}
|
||||
|
|
|
|||
81
Tests/SwiftGtkGenCoreTests/InterfaceGenerationTests.swift
Normal file
81
Tests/SwiftGtkGenCoreTests/InterfaceGenerationTests.swift
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// InterfaceGenerationTests.swift
|
||||
// Covers Phase C5: GObject interface method planning and rendering. An
|
||||
// interface method becomes a protocol *requirement* — a `func` signature with
|
||||
// no `public` modifier and no body — because the C symbol that implements it
|
||||
// lives on the conforming class, not the interface. Unplannable methods
|
||||
// (unsupported parameter types) are recorded as skips, mirroring class
|
||||
// members. These tests lock in that surface so a regression surfaces here.
|
||||
|
||||
import Testing
|
||||
|
||||
@testable import SwiftGtkGenCore
|
||||
|
||||
@Suite("Interface generation")
|
||||
struct InterfaceGenerationTests {
|
||||
/// A registry-backed context in the GLib module. The interface under test
|
||||
/// needs no named types — its methods use primitives and strings only.
|
||||
func makeContext() -> MapContext {
|
||||
let glib = Repository(namespaces: [Namespace(name: "GLib", version: "2.0")])
|
||||
let registry = TypeRegistry(repositories: ["GLib": glib])
|
||||
return MapContext(registry: registry, currentModule: "GLib", currentNamespace: "GLib")
|
||||
}
|
||||
|
||||
/// Renders a single planned interface to Swift source via the public module
|
||||
/// renderer, returning the interface's file body.
|
||||
func render(_ plan: InterfacePlan) -> String {
|
||||
let module = ModulePlan(
|
||||
module: "GLib",
|
||||
types: [.interface(plan)],
|
||||
skips: [],
|
||||
coverage: CoverageStats()
|
||||
)
|
||||
return renderModule(module)["\(plan.name).swift"] ?? ""
|
||||
}
|
||||
|
||||
@Test("Interface instance methods become protocol requirements")
|
||||
func methodsBecomeRequirements() throws {
|
||||
let iface = Interface(
|
||||
name: "Ping", cType: "GPing",
|
||||
methods: [
|
||||
Method(
|
||||
name: "get_id", cIdentifier: "g_ping_get_id",
|
||||
parameters: [Parameter(name: "self", type: .pointer, cType: "GPing*",
|
||||
isInstanceParameter: true)],
|
||||
returnValue: ReturnValue(type: .long)
|
||||
)
|
||||
]
|
||||
)
|
||||
let (plan, skips) = planInterface(iface, context: makeContext())
|
||||
#expect(skips.isEmpty)
|
||||
#expect(plan.methods.count == 1)
|
||||
#expect(plan.methods[0].name == "getId")
|
||||
|
||||
let source = render(plan)
|
||||
// A requirement — no `public`, no body.
|
||||
#expect(source.contains("func getId() -> Int"))
|
||||
#expect(!source.contains("public func getId"))
|
||||
#expect(!source.contains("g_ping_get_id")) // no body means no C call
|
||||
}
|
||||
|
||||
@Test("Unplannable interface methods are recorded as skips, not requirements")
|
||||
func unplannableMethodsSkip() throws {
|
||||
let iface = Interface(
|
||||
name: "Ping", cType: "GPing",
|
||||
methods: [
|
||||
// Variadic methods cannot be bridged — must skip, not appear.
|
||||
Method(
|
||||
name: "log", cIdentifier: "g_ping_log",
|
||||
parameters: [
|
||||
Parameter(name: "self", type: .pointer, cType: "GPing*",
|
||||
isInstanceParameter: true),
|
||||
Parameter(name: "...", type: .vaList, cType: ""),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
let (plan, skips) = planInterface(iface, context: makeContext())
|
||||
#expect(plan.methods.isEmpty)
|
||||
#expect(skips.count == 1)
|
||||
#expect(!render(plan).contains("func log"))
|
||||
}
|
||||
}
|
||||
95
Tests/SwiftGtkGenCoreTests/RecordGenerationTests.swift
Normal file
95
Tests/SwiftGtkGenCoreTests/RecordGenerationTests.swift
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// RecordGenerationTests.swift
|
||||
// Covers Phase C4: boxed-record memory management. A boxed wrapper owns its
|
||||
// pointer, so it needs a `deinit` that frees it and an `init(retaining:)` that
|
||||
// copies a borrowed pointer. The free/copy functions are resolved from the GIR
|
||||
// `free-function`/`copy-function` attributes when present, else from the
|
||||
// record's own `unref`/`free` and `ref`/`copy` methods. These tests lock in
|
||||
// that resolution and the rendered surface (`isolated deinit`, the copy init).
|
||||
|
||||
import Testing
|
||||
|
||||
@testable import SwiftGtkGenCore
|
||||
|
||||
@Suite("Record generation")
|
||||
struct RecordGenerationTests {
|
||||
func makeContext() -> MapContext {
|
||||
let glib = Repository(namespaces: [Namespace(name: "GLib", version: "2.0")])
|
||||
let registry = TypeRegistry(repositories: ["GLib": glib])
|
||||
return MapContext(registry: registry, currentModule: "GLib", currentNamespace: "GLib")
|
||||
}
|
||||
|
||||
/// Renders a single planned record to Swift source via the module renderer.
|
||||
func render(_ plan: RecordPlan) -> String {
|
||||
let module = ModulePlan(module: "GLib", types: [.record(plan)], skips: [], coverage: CoverageStats())
|
||||
return renderModule(module)["\(plan.name).swift"] ?? ""
|
||||
}
|
||||
|
||||
/// A record method taking only its instance parameter (a canonical
|
||||
/// `ref`/`unref`/`copy`/`free` shape).
|
||||
func releaseMethod(_ name: String, _ cIdentifier: String) -> Method {
|
||||
Method(name: name, cIdentifier: cIdentifier,
|
||||
parameters: [Parameter(name: "self", type: .pointer, isInstanceParameter: true)])
|
||||
}
|
||||
|
||||
@Test("Explicit free/copy attributes take priority over method scan")
|
||||
func explicitAttributesWin() {
|
||||
let record = Record(
|
||||
name: "Bytes", cType: "GBytes", getTypeFunction: "g_bytes_get_type",
|
||||
copyFunction: "g_bytes_ref", freeFunction: "g_bytes_unref",
|
||||
methods: [releaseMethod("ref", "g_bytes_ref_method"),
|
||||
releaseMethod("unref", "g_bytes_unref_method")]
|
||||
)
|
||||
#expect(resolvedFreeFunction(record) == "g_bytes_unref")
|
||||
#expect(resolvedCopyFunction(record) == "g_bytes_ref")
|
||||
}
|
||||
|
||||
@Test("Falls back to the record's own unref/ref methods")
|
||||
func methodScanFallback() {
|
||||
// No explicit attributes — resolve from methods. `unref`/`ref` are
|
||||
// preferred over `free`/`copy` when both exist.
|
||||
let record = Record(
|
||||
name: "TimeZone", cType: "GTimeZone", getTypeFunction: "g_time_zone_get_type",
|
||||
methods: [releaseMethod("copy", "g_time_zone_copy"),
|
||||
releaseMethod("free", "g_time_zone_free"),
|
||||
releaseMethod("ref", "g_time_zone_ref"),
|
||||
releaseMethod("unref", "g_time_zone_unref")]
|
||||
)
|
||||
#expect(resolvedFreeFunction(record) == "g_time_zone_unref")
|
||||
#expect(resolvedCopyFunction(record) == "g_time_zone_ref")
|
||||
}
|
||||
|
||||
@Test("A method taking extra arguments is not treated as a destructor")
|
||||
func destructorMustBeParameterless() {
|
||||
let record = Record(
|
||||
name: "Weird", cType: "GWeird", getTypeFunction: "g_weird_get_type",
|
||||
methods: [Method(name: "free", cIdentifier: "g_weird_free",
|
||||
parameters: [Parameter(name: "self", type: .pointer, isInstanceParameter: true),
|
||||
Parameter(name: "flags", type: .int32)])]
|
||||
)
|
||||
#expect(resolvedFreeFunction(record) == nil)
|
||||
}
|
||||
|
||||
@Test("Boxed record renders init(retaining:) and an isolated deinit")
|
||||
func rendersMemoryManagement() {
|
||||
let plan = RecordPlan(
|
||||
name: "VariantType", cType: "GVariantType",
|
||||
getTypeFunction: "g_variant_type_get_gtype",
|
||||
copyFunction: "g_variant_type_copy", freeFunction: "g_variant_type_free"
|
||||
)
|
||||
let source = render(plan)
|
||||
#expect(source.contains("public init(retaining pointer: UnsafeMutableRawPointer)"))
|
||||
#expect(source.contains("g_variant_type_copy(_instancePointer(pointer))"))
|
||||
#expect(source.contains("isolated deinit {"))
|
||||
#expect(source.contains("g_variant_type_free(_instancePointer(pointer))"))
|
||||
}
|
||||
|
||||
@Test("A record with no resolvable free renders no deinit")
|
||||
func noFreeNoDeinit() {
|
||||
let plan = RecordPlan(name: "PollFD", cType: "GPollFD")
|
||||
let source = render(plan)
|
||||
#expect(!source.contains("deinit"))
|
||||
#expect(!source.contains("retaining"))
|
||||
// The takingOwnership init is always present.
|
||||
#expect(source.contains("takingOwnership"))
|
||||
}
|
||||
}
|
||||
|
|
@ -420,6 +420,18 @@
|
|||
"reason" : "unknownType",
|
||||
"symbol" : "GObject.clear_signal_handler"
|
||||
},
|
||||
{
|
||||
"cIdentifier" : "g_type_plugin_complete_interface_info",
|
||||
"detail" : "parameter 'info': 'GObject.InterfaceInfo' has no GType registration or lifetime functions",
|
||||
"reason" : "plainRecord",
|
||||
"symbol" : "GObject.complete_interface_info"
|
||||
},
|
||||
{
|
||||
"cIdentifier" : "g_type_plugin_complete_type_info",
|
||||
"detail" : "parameter 'info': 'GObject.TypeInfo' has no GType registration or lifetime functions",
|
||||
"reason" : "plainRecord",
|
||||
"symbol" : "GObject.complete_type_info"
|
||||
},
|
||||
{
|
||||
"cIdentifier" : "g_signal_group_connect_data",
|
||||
"detail" : "parameter 'c_handler': callback 'GObject.Callback' not yet supported as a mapped type",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue