Generate GObject properties as computed vars (C6)
Properties render as Swift computed `var`s. Each accessor is planned
independently as either a delegation to the GIR getter=/setter= method
or the uniform GValue machinery (g_object_get_property/_set_property).
Delegation is preferred and inherits the method's nullability and
ownership (e.g. Binding.source becomes Object? because get_source is
nullable, where the GValue path produced a trap-prone non-optional
Object). It is chosen only when it yields one consistent Swift type;
otherwise both accessors fall back to GValue. GValue is also the
fallback for pure-GObject properties with no dedicated C accessor.
Enum/flags GValue getters bridge gint/guint to the Swift raw value via
numericCast; a bare cast would fail to compile on any enum-bearing tier.
Construct-only properties render read-only. Interface properties become
{ get } / { get set } requirements. GObject's fundamental G_TYPE_*
constants are emitted into its support file.
Adds 17 PropertyGenerationTests (incl. 5 delegation cases) and 2 runtime
smoke tests exercising a GValue round-trip and delegated getFlags() /
nullable getSource() off a real GBinding.
This commit is contained in:
parent
4dd74b0f65
commit
f85af8e6ce
6 changed files with 793 additions and 18 deletions
355
Tests/SwiftGtkGenCoreTests/PropertyGenerationTests.swift
Normal file
355
Tests/SwiftGtkGenCoreTests/PropertyGenerationTests.swift
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
// PropertyGenerationTests.swift
|
||||
// Covers Phase C6: GObject property planning and rendering. A property becomes
|
||||
// a Swift computed `var` backed by the GValue machinery
|
||||
// (`g_value_init` / `g_object_get_property` / `g_object_set_property`), or — for
|
||||
// interfaces — a `{ get }` / `{ get set }` protocol requirement. Properties
|
||||
// whose type has no `GValueOps` are recorded as skips.
|
||||
//
|
||||
// These tests also lock in the numeric bridging of enum/flags getters: a
|
||||
// generated Swift enum uses an `Int` raw value while `g_value_get_enum` returns
|
||||
// a `gint` (Int32), so the getter must bridge through `numericCast`. Emitting a
|
||||
// bare `EnumType(rawValue: g_value_get_enum(...))` fails to compile — the
|
||||
// regression that `enumPropertyGetterBridgesRawValueWithNumericCast` guards.
|
||||
|
||||
import Testing
|
||||
|
||||
@testable import SwiftGtkGenCore
|
||||
|
||||
@Suite("Property generation")
|
||||
struct PropertyGenerationTests {
|
||||
/// A GObject-local context: `currentModule == "GObject"` so mapped type
|
||||
/// names are bare (e.g. `Object`, `Orientation`). Registers the named types
|
||||
/// the property tests reference — a root object class, an enum, a bitfield,
|
||||
/// and an interface (used to exercise the unsupported-type skip path).
|
||||
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"),
|
||||
],
|
||||
interfaces: [
|
||||
Interface(name: "TypePlugin", cType: "GTypePlugin",
|
||||
getTypeFunction: "g_type_plugin_get_type"),
|
||||
],
|
||||
enumerations: [
|
||||
Enumeration(name: "Orientation", cType: "GtkOrientation",
|
||||
members: [
|
||||
EnumMember(name: "horizontal", value: "0",
|
||||
cIdentifier: "GTK_ORIENTATION_HORIZONTAL"),
|
||||
EnumMember(name: "vertical", value: "1",
|
||||
cIdentifier: "GTK_ORIENTATION_VERTICAL"),
|
||||
],
|
||||
getTypeFunction: "gtk_orientation_get_type"),
|
||||
],
|
||||
bitfields: [
|
||||
Bitfield(name: "StateFlags", cType: "GtkStateFlags",
|
||||
members: [
|
||||
EnumMember(name: "active", value: "1",
|
||||
cIdentifier: "GTK_STATE_FLAG_ACTIVE"),
|
||||
],
|
||||
getTypeFunction: "gtk_state_flags_get_type"),
|
||||
]
|
||||
)
|
||||
])
|
||||
let registry = TypeRegistry(repositories: ["GObject": gobject])
|
||||
return MapContext(registry: registry, currentModule: "GObject", currentNamespace: "GObject")
|
||||
}
|
||||
|
||||
/// Plans a one-off class carrying `properties` and renders it to Swift
|
||||
/// source, returning the class file body.
|
||||
func renderClass(named name: String, properties: [Property],
|
||||
methods: [Method] = []) -> (source: String, plan: ClassPlan, skips: [SkipEntry]) {
|
||||
let klass = Class(name: name, cType: "G\(name)", parent: nil,
|
||||
getTypeFunction: "g_\(name.lowercased())_get_type",
|
||||
methods: methods, properties: properties)
|
||||
let (plan, skips) = planClass(klass, context: makeContext())
|
||||
let module = ModulePlan(module: "GObject", types: [.class(plan)], skips: skips,
|
||||
coverage: CoverageStats())
|
||||
return (renderModule(module)["\(name).swift"] ?? "", plan, skips)
|
||||
}
|
||||
|
||||
/// A `self` instance parameter for building test methods.
|
||||
var selfParam: Parameter {
|
||||
Parameter(name: "self", type: .pointer, cType: "GThing*", isInstanceParameter: true)
|
||||
}
|
||||
|
||||
/// Builds an instance getter method `get_<x>` returning `type`.
|
||||
func getterMethod(_ girName: String, returns type: GIRType, nullable: Bool = false) -> Method {
|
||||
Method(name: girName, cIdentifier: "g_thing_\(girName)",
|
||||
parameters: [selfParam],
|
||||
returnValue: ReturnValue(type: type, isNullable: nullable))
|
||||
}
|
||||
|
||||
/// Builds an instance setter method `set_<x>` taking one argument of `type`.
|
||||
/// `cType` must be a real C spelling (e.g. `"const char*"`) for string
|
||||
/// arguments, which the parameter planner requires.
|
||||
func setterMethod(_ girName: String, arg argName: String, of type: GIRType,
|
||||
cType: String = "const char*", nullable: Bool = false) -> Method {
|
||||
Method(name: girName, cIdentifier: "g_thing_\(girName)",
|
||||
parameters: [selfParam, Parameter(name: argName, type: type, cType: cType, isNullable: nullable)],
|
||||
returnValue: ReturnValue(type: .void))
|
||||
}
|
||||
|
||||
// ── Primitive properties (the three the plan called out explicitly) ──
|
||||
|
||||
@Test("Readable Int32 property generates a GValue-backed getter")
|
||||
func readableInt32Property() throws {
|
||||
let prop = Property(name: "length", type: .int32, isReadable: true, isWritable: false)
|
||||
let (source, plan, skips) = renderClass(named: "Buffer", properties: [prop])
|
||||
#expect(skips.isEmpty)
|
||||
#expect(plan.properties.count == 1)
|
||||
#expect(source.contains("public var length: Int32"))
|
||||
#expect(source.contains("g_value_init(&gvalue, G_TYPE_INT)"))
|
||||
#expect(source.contains("g_value_get_int(&gvalue)"))
|
||||
#expect(source.contains("g_object_get_property(_instancePointer(pointer), \"length\", &gvalue)"))
|
||||
// Read-only: no setter.
|
||||
#expect(!source.contains("set {"))
|
||||
#expect(!source.contains("g_object_set_property"))
|
||||
}
|
||||
|
||||
@Test("Writable string property generates both a getter and a setter")
|
||||
func writableStringProperty() throws {
|
||||
let prop = Property(name: "label", type: .string, isReadable: true, isWritable: true)
|
||||
let (source, _, _) = renderClass(named: "Button", properties: [prop])
|
||||
#expect(source.contains("public var label: String"))
|
||||
#expect(source.contains("get {"))
|
||||
#expect(source.contains("set {"))
|
||||
#expect(source.contains("g_value_get_string(&gvalue)"))
|
||||
// The string setter routes newValue through withCString.
|
||||
#expect(source.contains("newValue.withCString"))
|
||||
#expect(source.contains("g_value_set_string(&gvalue, cstr)"))
|
||||
#expect(source.contains("g_object_set_property(_instancePointer(pointer), \"label\", &gvalue)"))
|
||||
}
|
||||
|
||||
@Test("Read-only boolean property generates a getter only")
|
||||
func readOnlyBooleanProperty() throws {
|
||||
let prop = Property(name: "visible", type: .boolean, isReadable: true, isWritable: false)
|
||||
let (source, _, _) = renderClass(named: "Widget", properties: [prop])
|
||||
#expect(source.contains("public var visible: Bool"))
|
||||
#expect(source.contains("g_value_get_boolean(&gvalue) != 0"))
|
||||
#expect(!source.contains("set {"))
|
||||
}
|
||||
|
||||
// ── Enum / flags numeric bridging (regression guard for the getter bug) ──
|
||||
|
||||
@Test("Enum property getter bridges the raw value through numericCast")
|
||||
func enumPropertyGetterBridgesRawValueWithNumericCast() throws {
|
||||
let prop = Property(name: "orientation",
|
||||
type: .typeRef("Orientation", namespace: "GObject"),
|
||||
isReadable: true, isWritable: false)
|
||||
let (source, plan, skips) = renderClass(named: "Orientable", properties: [prop])
|
||||
#expect(skips.isEmpty)
|
||||
#expect(plan.properties.count == 1)
|
||||
#expect(source.contains("public var orientation: Orientation"))
|
||||
#expect(source.contains("g_value_init(&gvalue, G_TYPE_ENUM)"))
|
||||
// `g_value_get_enum` returns Int32; the enum's raw value is Int, so the
|
||||
// result MUST be numericCast — a bare cast would not compile.
|
||||
#expect(source.contains("Orientation(rawValue: numericCast(g_value_get_enum(&gvalue)))!"))
|
||||
#expect(!source.contains("rawValue: g_value_get_enum(&gvalue))!"))
|
||||
}
|
||||
|
||||
@Test("Flags property getter bridges the raw value through numericCast")
|
||||
func flagsPropertyGetterBridgesRawValueWithNumericCast() throws {
|
||||
let prop = Property(name: "state-flags",
|
||||
type: .typeRef("StateFlags", namespace: "GObject"),
|
||||
isReadable: true, isWritable: false)
|
||||
let (source, _, skips) = renderClass(named: "Widget", properties: [prop])
|
||||
#expect(skips.isEmpty)
|
||||
#expect(source.contains("public var stateFlags: StateFlags"))
|
||||
#expect(source.contains("g_value_init(&gvalue, G_TYPE_FLAGS)"))
|
||||
#expect(source.contains("StateFlags(rawValue: numericCast(g_value_get_flags(&gvalue)))"))
|
||||
}
|
||||
|
||||
// ── Ownership, construct-only, naming, and object properties ──
|
||||
|
||||
@Test("Object property getter takes a retained reference (transfer none)")
|
||||
func objectPropertyGetterUsesRetaining() throws {
|
||||
let prop = Property(name: "child",
|
||||
type: .typeRef("Object", namespace: "GObject"),
|
||||
isReadable: true, isWritable: false)
|
||||
let (source, _, skips) = renderClass(named: "Bin", properties: [prop])
|
||||
#expect(skips.isEmpty)
|
||||
#expect(source.contains("public var child: Object"))
|
||||
#expect(source.contains("g_value_init(&gvalue, G_TYPE_OBJECT)"))
|
||||
#expect(source.contains("Object(retaining: g_value_get_object(&gvalue))"))
|
||||
}
|
||||
|
||||
@Test("Construct-only properties are treated as read-only")
|
||||
func constructOnlyPropertyIsReadOnly() throws {
|
||||
// Writable in GIR, but construct-only: a runtime setter would silently
|
||||
// no-op, so it must render as read-only.
|
||||
let prop = Property(name: "source", type: .string,
|
||||
isReadable: true, isWritable: true, isConstructOnly: true)
|
||||
let (source, plan, _) = renderClass(named: "Binding", properties: [prop])
|
||||
#expect(plan.properties.count == 1)
|
||||
#expect(plan.properties[0].setter == nil)
|
||||
#expect(source.contains("public var source: String"))
|
||||
#expect(!source.contains("set {"))
|
||||
#expect(!source.contains("g_object_set_property"))
|
||||
}
|
||||
|
||||
@Test("A kebab-case GIR name yields a camelCase Swift name and a preserved GObject name")
|
||||
func girNamePreservedWhileSwiftNameCamelCased() throws {
|
||||
let prop = Property(name: "source-property", type: .string,
|
||||
isReadable: true, isWritable: false)
|
||||
let (source, plan, _) = renderClass(named: "Binding", properties: [prop])
|
||||
#expect(plan.properties[0].swiftName == "sourceProperty")
|
||||
#expect(plan.properties[0].girName == "source-property")
|
||||
#expect(source.contains("public var sourceProperty: String"))
|
||||
// The original kebab-case name is what GObject knows the property by.
|
||||
#expect(source.contains("g_object_get_property(_instancePointer(pointer), \"source-property\", &gvalue)"))
|
||||
}
|
||||
|
||||
// ── Skips and filtering ──
|
||||
|
||||
@Test("A property whose type has no GValue support is skipped, not rendered")
|
||||
func unsupportedPropertyTypeIsSkipped() throws {
|
||||
// Interface-typed properties have no GValueOps mapping.
|
||||
let prop = Property(name: "plugin",
|
||||
type: .typeRef("TypePlugin", namespace: "GObject"),
|
||||
isReadable: true, isWritable: false)
|
||||
let (source, plan, skips) = renderClass(named: "Loader", properties: [prop])
|
||||
#expect(plan.properties.isEmpty)
|
||||
#expect(skips.count == 1)
|
||||
#expect(skips[0].reason == .unsupportedGValueCategory)
|
||||
#expect(!source.contains("var plugin"))
|
||||
}
|
||||
|
||||
@Test("Non-bindable properties are excluded entirely")
|
||||
func nonBindablePropertyIsExcluded() throws {
|
||||
let prop = Property(name: "internalThing", type: .int32,
|
||||
isReadable: true, isWritable: false,
|
||||
symbolInfo: SymbolInfo(isIntrospectable: false))
|
||||
let (source, plan, skips) = renderClass(named: "Widget", properties: [prop])
|
||||
// Not bindable => never even considered: no plan entry, no skip.
|
||||
#expect(plan.properties.isEmpty)
|
||||
#expect(skips.isEmpty)
|
||||
#expect(!source.contains("internalThing"))
|
||||
}
|
||||
|
||||
// ── Interface property requirements ──
|
||||
|
||||
@Test("A readable-only interface property becomes a { get } requirement")
|
||||
func interfaceReadOnlyPropertyIsGetRequirement() throws {
|
||||
let iface = Interface(
|
||||
name: "Orientable", cType: "GtkOrientable",
|
||||
properties: [
|
||||
Property(name: "orientation",
|
||||
type: .typeRef("Orientation", namespace: "GObject"),
|
||||
isReadable: true, isWritable: false),
|
||||
]
|
||||
)
|
||||
let (plan, skips) = planInterface(iface, context: makeContext())
|
||||
#expect(skips.isEmpty)
|
||||
#expect(plan.properties.count == 1)
|
||||
let module = ModulePlan(module: "GObject", types: [.interface(plan)], skips: [],
|
||||
coverage: CoverageStats())
|
||||
let source = renderModule(module)["Orientable.swift"] ?? ""
|
||||
#expect(source.contains("var orientation: Orientation { get }"))
|
||||
// A requirement — no body, no GValue machinery.
|
||||
#expect(!source.contains("g_object_get_property"))
|
||||
#expect(!source.contains("public var orientation"))
|
||||
}
|
||||
|
||||
@Test("A writable interface property becomes a { get set } requirement")
|
||||
func interfaceWritablePropertyIsGetSetRequirement() throws {
|
||||
let iface = Interface(
|
||||
name: "Editable", cType: "GtkEditable",
|
||||
properties: [
|
||||
Property(name: "text", type: .string, isReadable: true, isWritable: true),
|
||||
]
|
||||
)
|
||||
let (plan, _) = planInterface(iface, context: makeContext())
|
||||
let module = ModulePlan(module: "GObject", types: [.interface(plan)], skips: [],
|
||||
coverage: CoverageStats())
|
||||
let source = renderModule(module)["Editable.swift"] ?? ""
|
||||
#expect(source.contains("var text: String { get set }"))
|
||||
}
|
||||
|
||||
// ── getter=/setter= method delegation ──
|
||||
|
||||
@Test("A read+write property with getter=/setter= delegates to those methods")
|
||||
func readWriteDelegatesToAccessorMethods() throws {
|
||||
let prop = Property(name: "label", type: .string, isReadable: true, isWritable: true,
|
||||
getter: "get_label", setter: "set_label")
|
||||
let (source, plan, _) = renderClass(
|
||||
named: "Button", properties: [prop],
|
||||
methods: [getterMethod("get_label", returns: .string),
|
||||
setterMethod("set_label", arg: "label", of: .string)])
|
||||
// The accessor forwards to the generated methods; no GValue machinery.
|
||||
#expect(plan.properties[0].getter == .delegate(method: "getLabel", argumentLabel: nil))
|
||||
#expect(plan.properties[0].setter == .delegate(method: "setLabel", argumentLabel: "label"))
|
||||
#expect(source.contains("public var label: String {"))
|
||||
#expect(source.contains("getLabel()"))
|
||||
#expect(source.contains("setLabel(label: newValue)"))
|
||||
#expect(!source.contains("g_object_get_property"))
|
||||
#expect(!source.contains("g_object_set_property"))
|
||||
}
|
||||
|
||||
@Test("A read-only property with getter= delegates to the getter method")
|
||||
func readOnlyDelegatesToGetterMethod() throws {
|
||||
let prop = Property(name: "count", type: .int32, isReadable: true, isWritable: false,
|
||||
getter: "get_count")
|
||||
let (source, plan, _) = renderClass(
|
||||
named: "Model", properties: [prop],
|
||||
methods: [getterMethod("get_count", returns: .int32)])
|
||||
#expect(plan.properties[0].getter == .delegate(method: "getCount", argumentLabel: nil))
|
||||
#expect(plan.properties[0].setter == nil)
|
||||
#expect(source.contains("public var count: Int32"))
|
||||
#expect(source.contains("getCount()"))
|
||||
#expect(!source.contains("g_object_get_property"))
|
||||
}
|
||||
|
||||
@Test("A delegated getter inherits the method's nullability")
|
||||
func delegatedGetterInheritsNullability() throws {
|
||||
// The getter method returns Object? (nullable) — the property type must
|
||||
// follow it, which the uniform GValue path (non-optional Object) would
|
||||
// get wrong and could trap.
|
||||
let prop = Property(name: "source",
|
||||
type: .typeRef("Object", namespace: "GObject"),
|
||||
isReadable: true, isWritable: false, getter: "get_source")
|
||||
let (source, plan, _) = renderClass(
|
||||
named: "Binding", properties: [prop],
|
||||
methods: [getterMethod("get_source",
|
||||
returns: .typeRef("Object", namespace: "GObject"),
|
||||
nullable: true)])
|
||||
#expect(plan.properties[0].swiftType == "Object?")
|
||||
#expect(source.contains("public var source: Object?"))
|
||||
#expect(source.contains("getSource()"))
|
||||
}
|
||||
|
||||
@Test("Falls back to GValue when the named getter= method is unavailable")
|
||||
func fallsBackToGValueWhenMethodMissing() throws {
|
||||
// getter= names a method that was never planned (not in the class), so
|
||||
// the accessor must fall back to the GValue machinery.
|
||||
let prop = Property(name: "orientation",
|
||||
type: .typeRef("Orientation", namespace: "GObject"),
|
||||
isReadable: true, isWritable: false, getter: "get_orientation")
|
||||
let (source, plan, _) = renderClass(named: "Orientable", properties: [prop], methods: [])
|
||||
if case .gvalue = plan.properties[0].getter {} else {
|
||||
Issue.record("expected GValue fallback, got \(plan.properties[0].getter)")
|
||||
}
|
||||
#expect(source.contains("g_value_init(&gvalue, G_TYPE_ENUM)"))
|
||||
#expect(source.contains("numericCast(g_value_get_enum(&gvalue))"))
|
||||
}
|
||||
|
||||
@Test("Falls back to GValue when delegated getter and setter types disagree")
|
||||
func fallsBackToGValueOnTypeMismatch() throws {
|
||||
// Nullable getter (String?) but non-null setter (String): delegating
|
||||
// both would give the computed property two different types, so the
|
||||
// planner uses GValue for both instead.
|
||||
let prop = Property(name: "text", type: .string, isReadable: true, isWritable: true,
|
||||
getter: "get_text", setter: "set_text")
|
||||
let (source, plan, _) = renderClass(
|
||||
named: "Entry", properties: [prop],
|
||||
methods: [getterMethod("get_text", returns: .string, nullable: true),
|
||||
setterMethod("set_text", arg: "text", of: .string, nullable: false)])
|
||||
if case .gvalue = plan.properties[0].getter {} else {
|
||||
Issue.record("expected GValue fallback on type mismatch")
|
||||
}
|
||||
#expect(source.contains("g_object_get_property"))
|
||||
#expect(source.contains("g_object_set_property"))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue