472 lines
24 KiB
Swift
472 lines
24 KiB
Swift
// RendererCallableTests.swift
|
|
// Renderer-level tests for callable features that the compiler gate alone
|
|
// cannot catch: throws signatures, out-param tuple ordering, constructor
|
|
// error handling, and the memory-safety caller-allocates skip.
|
|
|
|
import Testing
|
|
|
|
@testable import GObjectGeneratorCore
|
|
|
|
@Suite("Renderer callables")
|
|
struct RendererCallableTests {
|
|
func makeContext() -> MapContext {
|
|
let gobject = Repository(namespaces: [
|
|
Namespace(
|
|
name: "GObject", version: "2.0",
|
|
classes: [
|
|
Class(name: "Object", cType: "GObject", parent: nil,
|
|
getTypeFunction: "g_object_get_type"),
|
|
Class(name: "InitiallyUnowned", cType: "GInitiallyUnowned",
|
|
parent: "Object",
|
|
getTypeFunction: "g_initially_unowned_get_type"),
|
|
]
|
|
)
|
|
])
|
|
let registry = TypeRegistry(repositories: ["GObject": gobject])
|
|
return MapContext(registry: registry, currentModule: "GObject", currentNamespace: "GObject")
|
|
}
|
|
|
|
func renderCallable(_ plan: CallablePlan) -> String {
|
|
let module = ModulePlan(
|
|
module: "GObject",
|
|
types: [.callable(plan)],
|
|
skips: [],
|
|
coverage: CoverageStats()
|
|
)
|
|
return renderModule(module)["Functions.swift"] ?? ""
|
|
}
|
|
|
|
// MARK: - C2: Throws + result! unwrap
|
|
|
|
@Test("Throwing function returning object emits throws, &error, and result!")
|
|
func throwingFunctionEmitsResultUnwrap() throws {
|
|
// A function that returns Object and throws.
|
|
let fn = GlobalFunction(
|
|
name: "load_object", cIdentifier: "g_load_object",
|
|
parameters: [Parameter(name: "path", type: .string, cType: "const char*")],
|
|
returnValue: ReturnValue(type: .typeRef("Object", namespace: "GObject")),
|
|
throwsGError: true
|
|
)
|
|
guard case .success(let plan) = planFunction(fn, context: makeContext()) else {
|
|
Issue.record("expected load_object to plan successfully"); return
|
|
}
|
|
let body = renderCallable(plan)
|
|
#expect(body.contains("throws"))
|
|
#expect(body.contains("&error"))
|
|
// Pointer-returning throwing function: result! unwrap before adoption
|
|
#expect(body.contains("result!") || body.contains("marshalReturn"))
|
|
}
|
|
|
|
// MARK: - Phase E4: no-unsafe-pointer public API policy
|
|
|
|
@Test("A function with a gpointer parameter renders @_spi(SGTKInternal) public func, hiding the pointer")
|
|
func gpointerParameterHidesFunctionBehindSPI() throws {
|
|
let fn = GlobalFunction(
|
|
name: "set_user_data", cIdentifier: "g_set_user_data",
|
|
parameters: [Parameter(name: "data", type: .pointer, cType: "gpointer")],
|
|
returnValue: ReturnValue(type: .void)
|
|
)
|
|
guard case .success(let plan) = planFunction(fn, context: makeContext()) else {
|
|
Issue.record("expected set_user_data to plan successfully"); return
|
|
}
|
|
let body = renderCallable(plan)
|
|
#expect(body.contains("@_spi(SGTKInternal) public func setUserData"))
|
|
}
|
|
|
|
@Test("A function returning a plain wrapper type (no raw pointer in its signature) stays plain public")
|
|
func wrapperReturnStaysPlainPublic() throws {
|
|
let fn = GlobalFunction(
|
|
name: "get_default_object", cIdentifier: "g_get_default_object",
|
|
parameters: [],
|
|
returnValue: ReturnValue(type: .typeRef("Object", namespace: "GObject"))
|
|
)
|
|
guard case .success(let plan) = planFunction(fn, context: makeContext()) else {
|
|
Issue.record("expected get_default_object to plan successfully"); return
|
|
}
|
|
let body = renderCallable(plan)
|
|
#expect(body.contains("public func getDefaultObject"))
|
|
#expect(!body.contains("@_spi(SGTKInternal) public func getDefaultObject"))
|
|
}
|
|
|
|
// MARK: - C3: Out-param tuples
|
|
|
|
@Test("Single out-param with no Swift return becomes the out-param's type")
|
|
func singleOutParamNoReturn() throws {
|
|
// A function that returns void but has one out-param.
|
|
let fn = GlobalFunction(
|
|
name: "get_data", cIdentifier: "g_get_data",
|
|
parameters: [
|
|
Parameter(name: "key", type: .string, cType: "const char*"),
|
|
Parameter(name: "result", type: .string, cType: "char**", direction: .out),
|
|
],
|
|
returnValue: ReturnValue(type: .void)
|
|
)
|
|
guard case .success(let plan) = planFunction(fn, context: makeContext()) else {
|
|
Issue.record("expected get_data to plan successfully"); return
|
|
}
|
|
let body = renderCallable(plan)
|
|
// Single out-param return: the type directly (no tuple label)
|
|
#expect(body.contains("func getData(key: String) -> String"))
|
|
}
|
|
|
|
@Test("Multiple out-params with no Swift return produce a tuple")
|
|
func multiOutParamNoReturnTuple() throws {
|
|
let fn = GlobalFunction(
|
|
name: "get_coords", cIdentifier: "g_get_coords",
|
|
parameters: [
|
|
Parameter(name: "id", type: .int32, cType: "gint"),
|
|
Parameter(name: "x", type: .int32, cType: "gint", direction: .out),
|
|
Parameter(name: "y", type: .int32, cType: "gint", direction: .out),
|
|
],
|
|
returnValue: ReturnValue(type: .void)
|
|
)
|
|
guard case .success(let plan) = planFunction(fn, context: makeContext()) else {
|
|
Issue.record("expected get_coords to plan successfully"); return
|
|
}
|
|
let body = renderCallable(plan)
|
|
#expect(body.contains("func getCoords(id: Int32) -> (x: Int32, y: Int32)"))
|
|
}
|
|
|
|
@Test("Out-param local type follows the c:type: mutable char** vs const char**")
|
|
func outParamLocalTypeFollowsCType() throws {
|
|
let mutableFn = GlobalFunction(
|
|
name: "next_token", cIdentifier: "g_next_token",
|
|
parameters: [
|
|
Parameter(name: "input", type: .string, cType: "const char*"),
|
|
Parameter(name: "endptr", type: .string, cType: "char**", direction: .out),
|
|
],
|
|
returnValue: ReturnValue(type: .void)
|
|
)
|
|
guard case .success(let mutablePlan) = planFunction(mutableFn, context: makeContext()) else {
|
|
Issue.record("expected next_token to plan successfully"); return
|
|
}
|
|
#expect(renderCallable(mutablePlan).contains("UnsafeMutablePointer<CChar>?"))
|
|
|
|
let constFn = GlobalFunction(
|
|
name: "peek_token", cIdentifier: "g_peek_token",
|
|
parameters: [
|
|
Parameter(name: "input", type: .string, cType: "const char*"),
|
|
Parameter(name: "endptr", type: .string, cType: "const char**", direction: .out),
|
|
],
|
|
returnValue: ReturnValue(type: .void)
|
|
)
|
|
guard case .success(let constPlan) = planFunction(constFn, context: makeContext()) else {
|
|
Issue.record("expected peek_token to plan successfully"); return
|
|
}
|
|
#expect(renderCallable(constPlan).contains("UnsafePointer<CChar>?"))
|
|
#expect(!renderCallable(constPlan).contains("UnsafeMutablePointer<CChar>?"))
|
|
}
|
|
|
|
@Test("Swift return with out-params produces a labeled tuple")
|
|
func returnWithOutParamsLabeledTuple() throws {
|
|
let fn = GlobalFunction(
|
|
name: "find_item", cIdentifier: "g_find_item",
|
|
parameters: [
|
|
Parameter(name: "id", type: .int32, cType: "gint"),
|
|
Parameter(name: "index", type: .int32, cType: "gint", direction: .out),
|
|
],
|
|
returnValue: ReturnValue(type: .boolean)
|
|
)
|
|
guard case .success(let plan) = planFunction(fn, context: makeContext()) else {
|
|
Issue.record("expected find_item to plan successfully"); return
|
|
}
|
|
let body = renderCallable(plan)
|
|
#expect(body.contains("-> (return: Bool, index: Int32)"))
|
|
}
|
|
|
|
// MARK: - Memory-safety caller-allocates skip
|
|
|
|
@Test("Caller-allocates out-param is skipped with the callerAllocates reason")
|
|
func callerAllocatesSkip() throws {
|
|
let fn = GlobalFunction(
|
|
name: "unichar_to_utf8", cIdentifier: "g_unichar_to_utf8",
|
|
parameters: [
|
|
Parameter(name: "c", type: .unichar, cType: "gunichar"),
|
|
Parameter(name: "outbuf", type: .string, cType: "char*", direction: .out,
|
|
callerAllocates: true),
|
|
]
|
|
)
|
|
guard case .skip(let entry) = planFunction(fn, context: makeContext()) else {
|
|
Issue.record("expected caller-allocates function to be skipped"); return
|
|
}
|
|
#expect(entry.reason == .outParameter)
|
|
#expect(entry.detail.contains("caller-allocates"))
|
|
}
|
|
|
|
// MARK: - Throwing constructor
|
|
|
|
@Test("Throwing constructor emits throws, &error, GLibError, and self.init(takingOwnership:)")
|
|
func throwingConstructorEmitsErrorHandling() throws {
|
|
// Construct a class with a throwing constructor.
|
|
let klass = Class(
|
|
name: "File", cType: "GFile", parent: "Object",
|
|
getTypeFunction: "g_file_get_type",
|
|
constructors: [
|
|
Constructor(name: "new", cIdentifier: "g_file_new",
|
|
parameters: [
|
|
Parameter(name: "path", type: .string, cType: "const char*"),
|
|
],
|
|
returnValue: ReturnValue(
|
|
transferOwnership: .full),
|
|
throwsGError: true),
|
|
]
|
|
)
|
|
let (plan, _) = planClass(klass, context: makeContext())
|
|
let module = ModulePlan(module: "GObject", types: [.class(plan)], skips: [],
|
|
coverage: CoverageStats())
|
|
let files = renderModule(module)
|
|
let src = files["File.swift"] ?? ""
|
|
#expect(src.contains("convenience init(path: String) throws"))
|
|
#expect(src.contains("&error"))
|
|
#expect(src.contains("throw GLibError(consuming: e)"))
|
|
#expect(src.contains("self.init(takingOwnership:"))
|
|
}
|
|
|
|
// MARK: - Out-param constructor skip
|
|
|
|
@Test("Constructor with out-params is skipped with constructorOutParams")
|
|
func outParamConstructorSkipped() throws {
|
|
let klass = Class(
|
|
name: "Stream", cType: "GStream", parent: "Object",
|
|
getTypeFunction: "g_stream_get_type",
|
|
constructors: [
|
|
Constructor(name: "new", cIdentifier: "g_stream_new",
|
|
parameters: [
|
|
Parameter(name: "name", type: .string, cType: "const char*"),
|
|
Parameter(name: "fd", type: .int32, cType: "gint",
|
|
direction: .out),
|
|
],
|
|
returnValue: ReturnValue(
|
|
transferOwnership: .full)),
|
|
]
|
|
)
|
|
let (plan, skips) = planClass(klass, context: makeContext())
|
|
// Constructor should be skipped — no constructor plan, skip entry recorded.
|
|
#expect(plan.constructors.isEmpty)
|
|
let outSkip = skips.first { $0.reason == .constructorOutParams }
|
|
#expect(outSkip != nil)
|
|
}
|
|
// MARK: - E1: throwing constructor with string params doesn't nest self.init
|
|
|
|
@Test("Throwing constructor with a String param threads the result out of withCString instead of nesting self.init")
|
|
func throwingConstructorWithStringParamAvoidsNestedSelfInit() throws {
|
|
// `self.init` (a delegating initializer call) is illegal inside any
|
|
// closure, including a non-escaping `withCString` trailing closure.
|
|
// Regression guard for the tier-2 DBusConnection/DBusProxy bug where
|
|
// string-parameterised throwing constructors called self.init from
|
|
// inside the withCString closure.
|
|
let klass = Class(
|
|
name: "Proxy", cType: "GProxy", parent: "Object",
|
|
getTypeFunction: "g_proxy_get_type",
|
|
constructors: [
|
|
Constructor(name: "new_for_bus_sync", cIdentifier: "g_proxy_new_for_bus_sync",
|
|
parameters: [
|
|
Parameter(name: "name", type: .string, cType: "const char*"),
|
|
],
|
|
returnValue: ReturnValue(transferOwnership: .full),
|
|
throwsGError: true),
|
|
]
|
|
)
|
|
let (plan, _) = planClass(klass, context: makeContext())
|
|
let module = ModulePlan(module: "GObject", types: [.class(plan)], skips: [],
|
|
coverage: CoverageStats())
|
|
let src = renderModule(module)["Proxy.swift"] ?? ""
|
|
// The C call (returning the result) lives INSIDE the withCString
|
|
// closure as its trailing/implicit-return expression...
|
|
#expect(src.contains("g_proxy_new_for_bus_sync(cString0, &error)"))
|
|
// ...while `self.init` runs OUTSIDE every closure, never nested.
|
|
#expect(!src.contains("cString0, &error)\n self.init"))
|
|
let lines = src.components(separatedBy: "\n")
|
|
guard let selfInitLine = lines.first(where: { $0.contains("self.init(takingOwnership:") }) else {
|
|
Issue.record("expected a self.init(takingOwnership:) line"); return
|
|
}
|
|
// Indentation of 8 spaces == top-level init body scope, not nested
|
|
// one level inside the withCString closure (12 spaces).
|
|
#expect(selfInitLine.hasPrefix(" self.init"))
|
|
}
|
|
|
|
// MARK: - E1: constructor signature deduplication
|
|
|
|
@Test("Two constructors with the same rendered init signature but different Swift names dedup to one, keeping the first")
|
|
func constructorsWithIdenticalSignatureDedup() throws {
|
|
// `init(...)` never carries the constructor's Swift name (it always
|
|
// renders as bare `init`), so two GIR constructors mapping to
|
|
// DIFFERENT Swift names (`newFinish` vs `newForAddressFinish`) but
|
|
// the IDENTICAL parameter/throws/return shape still collide at
|
|
// `init(res:)`. Regression guard for the tier-2 DBusConnection bug.
|
|
let resParam = Parameter(name: "res", type: .typeRef("AsyncResult", namespace: "GObject"),
|
|
cType: "GAsyncResult*")
|
|
let klass = Class(
|
|
name: "Connection", cType: "GConnection", parent: "Object",
|
|
getTypeFunction: "g_connection_get_type",
|
|
constructors: [
|
|
Constructor(name: "new_finish", cIdentifier: "g_connection_new_finish",
|
|
parameters: [resParam],
|
|
returnValue: ReturnValue(transferOwnership: .full),
|
|
throwsGError: true),
|
|
Constructor(name: "new_for_address_finish", cIdentifier: "g_connection_new_for_address_finish",
|
|
parameters: [resParam],
|
|
returnValue: ReturnValue(transferOwnership: .full),
|
|
throwsGError: true),
|
|
]
|
|
)
|
|
let ctx = MapContext(
|
|
registry: TypeRegistry(repositories: ["GObject": Repository(namespaces: [
|
|
Namespace(name: "GObject", version: "2.0", classes: [
|
|
Class(name: "Object", cType: "GObject", parent: nil, getTypeFunction: "g_object_get_type"),
|
|
Class(name: "AsyncResult", cType: "GAsyncResult", parent: "Object",
|
|
getTypeFunction: "g_async_result_get_type"),
|
|
])
|
|
])]),
|
|
currentModule: "GObject", currentNamespace: "GObject"
|
|
)
|
|
let (plan, skips) = planClass(klass, context: ctx)
|
|
#expect(plan.constructors.count == 1)
|
|
#expect(plan.constructors[0].cIdentifier == "g_connection_new_finish")
|
|
let dedupSkip = skips.first { $0.reason == .nameCollision }
|
|
#expect(dedupSkip?.cIdentifier == "g_connection_new_for_address_finish")
|
|
|
|
let module = ModulePlan(module: "GObject", types: [.class(plan)], skips: [],
|
|
coverage: CoverageStats())
|
|
let src = renderModule(module)["Connection.swift"] ?? ""
|
|
let occurrences = src.components(separatedBy: "convenience init(res: AsyncResult) throws").count - 1
|
|
#expect(occurrences == 1)
|
|
}
|
|
|
|
// MARK: - E1: pointer out-param zero-initializes to nil, not 0
|
|
|
|
@Test("A gpointer out-param local variable initializes to nil, not the integer 0")
|
|
func pointerOutParamInitializesToNil() throws {
|
|
// `.direct` marshalIn covers both numeric out-params (`gint*` → `0`)
|
|
// and raw-pointer out-params (`gpointer*` → `UnsafeMutableRawPointer?`,
|
|
// which `= 0` cannot initialize). Regression guard for the tier-2
|
|
// FileInfo.getAttributeData bug.
|
|
let fn = GlobalFunction(
|
|
name: "get_attribute_data", cIdentifier: "g_file_info_get_attribute_data",
|
|
parameters: [
|
|
Parameter(name: "attribute", type: .string, cType: "const char*"),
|
|
Parameter(name: "value_pp", type: .pointer, cType: "gpointer*", direction: .out),
|
|
],
|
|
returnValue: ReturnValue(type: .void)
|
|
)
|
|
guard case .success(let plan) = planFunction(fn, context: makeContext()) else {
|
|
Issue.record("expected get_attribute_data to plan successfully"); return
|
|
}
|
|
let body = renderCallable(plan)
|
|
#expect(body.contains("var out0: UnsafeMutableRawPointer? = nil"))
|
|
#expect(!body.contains("var out0: UnsafeMutableRawPointer? = 0"))
|
|
}
|
|
|
|
// MARK: - E2: no unconditional Foundation import (collides with Gio.InputStream)
|
|
|
|
@Test("Generated module and support headers do not import Foundation")
|
|
func generatedHeadersOmitFoundationImport() throws {
|
|
let module = ModulePlan(module: "GLib", types: [], skips: [], coverage: CoverageStats())
|
|
let files = renderModule(module)
|
|
for (name, content) in files {
|
|
#expect(!content.contains("import Foundation"), "\(name) unexpectedly imports Foundation")
|
|
}
|
|
}
|
|
|
|
// MARK: - E3: nullable string constructor params
|
|
|
|
@Test("Constructor with nullable string parameter generates String? and _withOptionalCString")
|
|
func nullableStringConstructorParam() throws {
|
|
let klass = Class(
|
|
name: "Alert", cType: "GAlert", parent: "Object",
|
|
getTypeFunction: "g_alert_get_type",
|
|
constructors: [
|
|
Constructor(name: "new", cIdentifier: "g_alert_new",
|
|
parameters: [
|
|
Parameter(name: "message", type: .string,
|
|
cType: "const char*", isNullable: true),
|
|
],
|
|
returnValue: ReturnValue(transferOwnership: .full)),
|
|
]
|
|
)
|
|
let (plan, _) = planClass(klass, context: makeContext())
|
|
let module = ModulePlan(module: "GObject", types: [.class(plan)], skips: [],
|
|
coverage: CoverageStats())
|
|
let src = renderModule(module)["Alert.swift"] ?? ""
|
|
// The signature must show String? for the nullable string parameter.
|
|
#expect(src.contains("convenience init(message: String?)"))
|
|
// The body wraps the nullable param with _withOptionalCString.
|
|
#expect(src.contains("_withOptionalCString(message)"))
|
|
// The convenience init must NOT be SPI-gated (check the init line is not preceded by @_spi).
|
|
let initLines = src.components(separatedBy: "\n")
|
|
let convenienceLines = initLines.filter { $0.contains("convenience init") }
|
|
#expect(!convenienceLines.isEmpty, "expected a convenience init line")
|
|
for line in convenienceLines {
|
|
#expect(!line.contains("@_spi"), "convenience init should not be SPI-gated, got: \(line)")
|
|
}
|
|
// Uses takingOwnership for transfer-ownership="full".
|
|
if !src.contains("self.init(takingOwnership:") {
|
|
Issue.record("expected self.init(takingOwnership:) for transfer-ownership=full")
|
|
}
|
|
}
|
|
|
|
@Test("Constructor with mixed nullable and required string params generates correct bridging")
|
|
func constructorWithMixedNullableAndRequiredParams() throws {
|
|
let klass = Class(
|
|
name: "Dialog", cType: "GAlertDialog", parent: "Object",
|
|
getTypeFunction: "g_alert_dialog_get_type",
|
|
constructors: [
|
|
Constructor(name: "new", cIdentifier: "g_alert_dialog_new",
|
|
parameters: [
|
|
Parameter(name: "heading", type: .string,
|
|
cType: "const char*", isNullable: true),
|
|
Parameter(name: "body", type: .string,
|
|
cType: "const char*"),
|
|
],
|
|
returnValue: ReturnValue(transferOwnership: .full)),
|
|
]
|
|
)
|
|
let (plan, _) = planClass(klass, context: makeContext())
|
|
let module = ModulePlan(module: "GObject", types: [.class(plan)], skips: [],
|
|
coverage: CoverageStats())
|
|
let src = renderModule(module)["Dialog.swift"] ?? ""
|
|
// The signature must show String? for the nullable param and String for the required param.
|
|
#expect(src.contains("convenience init(heading: String?, body: String)"))
|
|
// The nullable param gets _withOptionalCString, the required param gets .withCString.
|
|
#expect(src.contains("_withOptionalCString(heading)"))
|
|
#expect(src.contains("body.withCString"))
|
|
// The convenience init line must NOT be SPI-gated (the class file has SPI-gated
|
|
// designated inits, but the convenience init itself is public).
|
|
let initLines2 = src.components(separatedBy: "\n")
|
|
let convenienceLines2 = initLines2.filter { $0.contains("convenience init") }
|
|
#expect(!convenienceLines2.isEmpty, "expected a convenience init line")
|
|
for line in convenienceLines2 {
|
|
#expect(!line.contains("@_spi"), "convenience init should not be SPI-gated, got: \(line)")
|
|
}
|
|
}
|
|
|
|
// MARK: - E3: constructor borrowing ownership
|
|
|
|
@Test("Constructor with ownership .none return generates convenience init calling self.init(retaining:)")
|
|
func constructorBorrowedReturnUsesRetaining() throws {
|
|
let klass = Class(
|
|
name: "Borrowed", cType: "GBorrowed", parent: "Object",
|
|
getTypeFunction: "g_borrowed_get_type",
|
|
constructors: [
|
|
Constructor(name: "new", cIdentifier: "g_borrowed_new",
|
|
parameters: [
|
|
Parameter(name: "name", type: .string,
|
|
cType: "const char*"),
|
|
],
|
|
returnValue: ReturnValue(transferOwnership: .none)),
|
|
]
|
|
)
|
|
let (plan, _) = planClass(klass, context: makeContext())
|
|
let module3 = ModulePlan(module: "GObject", types: [.class(plan)], skips: [],
|
|
coverage: CoverageStats())
|
|
let src = renderModule(module3)["Borrowed.swift"] ?? ""
|
|
#expect(src.contains("self.init(retaining:"), "expected self.init(retaining:) for transfer-ownership=none, got: \(src)")
|
|
#expect(!src.contains("self.init(takingOwnership:"), "should NOT use init(takingOwnership:) for transfer-ownership=none")
|
|
// The convenience init line must NOT be SPI-gated.
|
|
let initLines3 = src.components(separatedBy: "\n")
|
|
let convenienceLines3 = initLines3.filter { $0.contains("convenience init") }
|
|
for line in convenienceLines3 {
|
|
#expect(!line.contains("@_spi"), "convenience init should not be SPI-gated, got: \(line)")
|
|
}
|
|
}
|
|
}
|