1
0
Fork 0

Add Tier 4 Gdk/Gsk bindings and E3 review remediation

Generates Gdk and Gsk wrappers (330/417 and 140/183 callables), with runtime smoke tests linked against real libgtk-4. Also fixes a latent cross-module dropped-type reference bug, corrects the init(takingOwnership:) doc for no-free records, restores a precise filename safety check, and adds unit test coverage for four previously compile-gate-only branches.
This commit is contained in:
Brendan Szymanski 2026-07-20 12:37:54 -04:00
parent ea41629a1f
commit dac435ec89
15 changed files with 446 additions and 41 deletions

View file

@ -76,7 +76,11 @@ public enum MarshalOut: Equatable, Sendable {
case gbooleanToBool case gbooleanToBool
/// Copy a C string, optionally freeing the source. /// Copy a C string, optionally freeing the source.
/// - Parameter free: When `true`, the caller owns the string and must `g_free` it. /// - Parameter free: When `true`, the caller owns the string and must `g_free` it.
case stringCopy(free: Bool) /// - Parameter constPointee: Whether the C out-param pointee is `const
/// char*` (`true`) rather than mutable `char*` (`false`). Only
/// meaningful for out-parameters (see `outParamLocalType`); irrelevant
/// for in-params (bridged via `withCString`) and return values.
case stringCopy(free: Bool, constPointee: Bool = true)
/// Wrap an object pointer, optionally sinking a floating ref. /// Wrap an object pointer, optionally sinking a floating ref.
/// - Parameter sink: When `true`, call `g_object_ref_sink` (for InitiallyUnowned constructors). /// - Parameter sink: When `true`, call `g_object_ref_sink` (for InitiallyUnowned constructors).
case objectWrap(sink: Bool) case objectWrap(sink: Bool)

View file

@ -96,10 +96,14 @@ public func renderModule(_ plan: ModulePlan) -> [String: String] {
return files return files
} }
/// Tests whether a generated filename follows the PascalCase convention starts /// Tests whether a generated filename follows the PascalCase convention starts
/// with an uppercase ASCII letter, contains only ASCII letters/digits, and has /// with an uppercase ASCII letter, contains only ASCII letters/digits, and
/// no run of more than 3 consecutive uppercase letters (catches unconverted C /// never has a run of more than 3 consecutive uppercase letters immediately
/// spellings like `cclosureMarshalBOOLEANFLAGS.swift` while allowing legitimate /// preceded by a lowercase letter. That last rule rejects unconverted C
/// 23-letter acronym runs like `IOChannel.swift`, `FileIOStream.swift`). /// spellings (`MarshalBOOLEAN`) while still accepting legitimate acronym
/// runs, since those never follow a lowercase letter mid-name (`RGBA`,
/// `GLAPI`, `DNDEvent`, `IOChannel`, `FileIOStream`).
/// Per-symbol filenames are only ever derived from authoritative GIR type
/// names (constants/functions/callbacks are merged into fixed-name files).
/// ///
/// - Parameter filename: A relative filename ending in `.swift`. /// - Parameter filename: A relative filename ending in `.swift`.
/// - Returns: `true` when the name is conventional. /// - Returns: `true` when the name is conventional.
@ -109,9 +113,15 @@ public func isValidGeneratedFileName(_ filename: String) -> Bool {
guard let first = base.first, first.isUppercase else { return false } guard let first = base.first, first.isUppercase else { return false }
guard base.allSatisfy({ ($0.isLetter && $0.isASCII) || $0.isNumber }) else { return false } guard base.allSatisfy({ ($0.isLetter && $0.isASCII) || $0.isNumber }) else { return false }
var run = 0 var run = 0
var precededByLower = false
for ch in base { for ch in base {
run = ch.isUppercase ? run + 1 : 0 if ch.isUppercase && ch.isASCII {
if run > 3 { return false } run += 1
if run > 3 && precededByLower { return false }
} else {
run = 0
precededByLower = ch.isLowercase && ch.isASCII
}
} }
return true return true
} }
@ -461,8 +471,14 @@ private func renderRecord(_ plan: RecordPlan) -> String {
lines.append("public final class \(plan.name) {") lines.append("public final class \(plan.name) {")
lines.append(" public let pointer: UnsafeMutableRawPointer") lines.append(" public let pointer: UnsafeMutableRawPointer")
lines.append("") lines.append("")
lines.append(" /// Adopts an owned boxed pointer; the wrapper takes responsibility") if plan.freeFunction != nil {
lines.append(" /// for freeing it. Use for `transfer-ownership=\"full\"` returns.") lines.append(" /// Adopts an owned boxed pointer; the wrapper takes responsibility")
lines.append(" /// for freeing it. Use for `transfer-ownership=\"full\"` returns.")
} else {
lines.append(" /// Stores a boxed pointer. This wrapper has no known free function, so it")
lines.append(" /// never frees the pointee — safe for borrowed (transfer-none) values such")
lines.append(" /// as signal parameters.")
}
lines.append(" public init(takingOwnership pointer: UnsafeMutableRawPointer) {") lines.append(" public init(takingOwnership pointer: UnsafeMutableRawPointer) {")
lines.append(" self.pointer = pointer") lines.append(" self.pointer = pointer")
lines.append(" }") lines.append(" }")
@ -768,7 +784,20 @@ private func renderWrapperExpr(for p: ParameterPlan, rawName: String, ownerIsInt
return "\(p.mapping.swiftType)(retaining: \(rawName))" return "\(p.mapping.swiftType)(retaining: \(rawName))"
} }
switch p.mapping.marshalIn { switch p.mapping.marshalIn {
case .boxedPointer, .objectPointer: case .boxedPointer:
let isOptional = p.mapping.swiftType.hasSuffix("?")
let baseType = isOptional ? String(p.mapping.swiftType.dropLast()) : p.mapping.swiftType
// A signal parameter is a borrowed pointer (transfer none); wrapping
// it must copy/ref so the wrapper owns an independent instance. Boxed
// records without a GIR copy-function (e.g. GdkToplevelSize) have no
// `init(retaining:)` (only emitted when one is known see
// renderRecord); adopt the borrowed pointer via `takingOwnership:`
// instead, matching the only initializer such records expose.
if case .boxedWrap(_, let copyFn) = p.mapping.marshalOut, copyFn == nil {
return "\(baseType)(takingOwnership: \(rawName))"
}
return "\(baseType)(retaining: \(rawName))"
case .objectPointer:
let isOptional = p.mapping.swiftType.hasSuffix("?") let isOptional = p.mapping.swiftType.hasSuffix("?")
let baseType = isOptional ? String(p.mapping.swiftType.dropLast()) : p.mapping.swiftType let baseType = isOptional ? String(p.mapping.swiftType.dropLast()) : p.mapping.swiftType
return "\(baseType)(retaining: \(rawName))" return "\(baseType)(retaining: \(rawName))"
@ -926,13 +955,31 @@ private func callbackBoxRelease(_ plan: CallablePlan, indent: String) -> [String
private func outParamLocalType(_ param: ParameterPlan) -> String { private func outParamLocalType(_ param: ParameterPlan) -> String {
switch param.mapping.marshalIn { switch param.mapping.marshalIn {
case .stringToC: case .stringToC:
if case .stringCopy(_, let constPointee) = param.mapping.marshalOut, constPointee {
// c:type is `const char**`: the Clang importer expects
// UnsafePointer<CChar>? as the pointee, not
// UnsafeMutablePointer<CChar>? (that's only correct when the
// callee hands over a mutable/owned `char**`).
return "UnsafePointer<CChar>?"
}
return "UnsafeMutablePointer<CChar>?" return "UnsafeMutablePointer<CChar>?"
case .boolToGboolean: case .boolToGboolean:
return "Int32" return "Int32"
case .direct, .numericCast: case .direct, .numericCast:
return param.mapping.cSwiftType // GIR `nullable="1"` on an out-param describes whether the ARGUMENT
// itself is omittable (caller may pass NULL to skip it) this
// generator always allocates a local and passes `&local`, so that
// never applies. For scalar pointees the type-mapper's blanket
// `optionalised(nullable:)` still wraps `Int32` `Int32?`, which
// doesn't match the non-optional `UnsafeMutablePointer<Int32>` the
// Clang importer expects. Raw/object/boxed pointees are exempt:
// their cSwiftType is `UnsafeMutableRawPointer?`, optional from the
// base mapping itself (a real nullable pointee), not from this wrap.
let t = param.mapping.cSwiftType
return t == "UnsafeMutableRawPointer?" ? t : (t.hasSuffix("?") ? String(t.dropLast()) : t)
case .enumRaw, .bitfieldRaw: case .enumRaw, .bitfieldRaw:
return param.mapping.cSwiftType let t = param.mapping.cSwiftType
return t.hasSuffix("?") ? String(t.dropLast()) : t
default: default:
return "UnsafeMutablePointer<Int8>?" return "UnsafeMutablePointer<Int8>?"
} }
@ -967,11 +1014,12 @@ private func outParamValueExpr(_ param: ParameterPlan, varName: String) -> Strin
return "numericCast(\(varName))" return "numericCast(\(varName))"
case .gbooleanToBool: case .gbooleanToBool:
return "\(varName) != 0" return "\(varName) != 0"
case .stringCopy: case .stringCopy(let free, _):
let note = free ? " /* TODO: transfer-full out-string not freed */" : ""
if param.mapping.swiftType.hasSuffix("?") { if param.mapping.swiftType.hasSuffix("?") {
return "\(varName).map { String(cString: $0) } /* TODO: transfer-full out-string not freed */" return "\(varName).map { String(cString: $0) }\(note)"
} }
return "String(cString: \(varName)!) /* TODO: transfer-full out-string not freed */" return "String(cString: \(varName)!)\(note)"
case .enumFromRaw(let swiftType): case .enumFromRaw(let swiftType):
return "\(swiftType)(rawValue: numericCast(\(varName).rawValue))!" return "\(swiftType)(rawValue: numericCast(\(varName).rawValue))!"
case .bitfieldFromRaw(let swiftType): case .bitfieldFromRaw(let swiftType):
@ -1413,7 +1461,7 @@ private func marshalReturn(_ cCall: String, mapping: Mapping) -> String {
return "numericCast(\(cCall))" return "numericCast(\(cCall))"
case .gbooleanToBool: case .gbooleanToBool:
return "\(cCall) != 0" return "\(cCall) != 0"
case .stringCopy(let free): case .stringCopy(let free, _):
if mapping.swiftType.hasSuffix("?") { if mapping.swiftType.hasSuffix("?") {
let freeStr = free ? "/* TODO: g_free */" : "" let freeStr = free ? "/* TODO: g_free */" : ""
return "\(cCall).map { String(cString: $0) \(freeStr) }" return "\(cCall).map { String(cString: $0) \(freeStr) }"

View file

@ -152,12 +152,7 @@ private func planNamespace(_ ns: Namespace, context: MapContext) -> ModulePlan {
// (enums, bitfields, records, classes, interfaces, aliases), never to // (enums, bitfields, records, classes, interfaces, aliases), never to
// constants/functions/callbacks. // constants/functions/callbacks.
func duplicateDependencyModule(_ girSimpleName: String) -> String? { func duplicateDependencyModule(_ girSimpleName: String) -> String? {
for dep in context.dependencyModules { context.registry.droppedShadow("\(ns.name).\(girSimpleName)")
if context.registry.resolve(name: girSimpleName, namespace: dep) != nil {
return dep
}
}
return nil
} }
func skipIfDuplicate(_ girSimpleName: String) -> Bool { func skipIfDuplicate(_ girSimpleName: String) -> Bool {
guard let dep = duplicateDependencyModule(girSimpleName) else { return false } guard let dep = duplicateDependencyModule(girSimpleName) else { return false }
@ -569,6 +564,11 @@ private let knownMissingCFunctions: Set<String> = [
// Declared in the GdkPixbuf GIR but not exported through the public // Declared in the GdkPixbuf GIR but not exported through the public
// <gdk-pixbuf/gdk-pixbuf.h> umbrella header. // <gdk-pixbuf/gdk-pixbuf.h> umbrella header.
"gdk_pixbuf_non_anim_new", "gdk_pixbuf_non_anim_new",
// Declared in <gsk/broadway/gskbroadwayrenderer.h>, which the public
// umbrella (<gsk/gsk.h>) deliberately does not include Broadway is an
// optional backend; unlike the GPU renderers (<gsk/gpu/gskglrenderer.h>,
// <gsk/gpu/gskvulkanrenderer.h>, both included), its header is excluded.
"gsk_broadway_renderer_new",
] ]
let knownMisleadingCFunctions: Set<String> = [ let knownMisleadingCFunctions: Set<String> = [
@ -1406,7 +1406,8 @@ func planParameters(
// Out-params are collected and returned as Swift values rather than // Out-params are collected and returned as Swift values rather than
// passed as arguments. Map the VALUE type (not the pointer-to-pointer). // passed as arguments. Map the VALUE type (not the pointer-to-pointer).
let mappingResult = Result { try map(param.type, nullable: param.isNullable, let mappingResult = Result { try map(param.type, nullable: param.isNullable,
transfer: param.transferOwnership, context: context) } transfer: param.transferOwnership, context: context,
cType: param.cType) }
switch mappingResult { switch mappingResult {
case .success(let paramMapping): case .success(let paramMapping):
if !paramMapping.isReadyForCallables { if !paramMapping.isReadyForCallables {

View file

@ -138,9 +138,10 @@ public func map(
_ type: GIRType, _ type: GIRType,
nullable: Bool, nullable: Bool,
transfer: TransferOwnership, transfer: TransferOwnership,
context: MapContext context: MapContext,
cType: String = ""
) throws(MapError) -> Mapping { ) throws(MapError) -> Mapping {
let result: Mapping = try _mapValue(type: type, transfer: transfer, context: context) let result: Mapping = try _mapValue(type: type, transfer: transfer, context: context, cType: cType)
return result.optionalised(nullable: nullable) return result.optionalised(nullable: nullable)
} }
@ -151,7 +152,8 @@ public func map(
private func _mapValue( private func _mapValue(
type: GIRType, type: GIRType,
transfer: TransferOwnership, transfer: TransferOwnership,
context: MapContext context: MapContext,
cType: String = ""
) throws(MapError) -> Mapping { ) throws(MapError) -> Mapping {
switch type { switch type {
@ -169,9 +171,17 @@ private func _mapValue(
case .float, .double: case .float, .double:
return try mapPrimitive(type) return try mapPrimitive(type)
case .string: case .string:
return .stringMapping(free: transfer == .full) // A GIR out-param's c:type spells `const char**` when the pointee
// is borrowed (no free needed) but `char**` when it points into
// caller/callee-owned storage the generator must not treat as
// const (e.g. g_ascii_strtod's endptr, transfer-ownership="none"
// yet c:type="gchar**"). transfer-ownership alone is not a
// reliable proxy for pointee constness; the raw c:type is.
let constOut = cType.isEmpty || cType.hasPrefix("const")
return .stringMapping(free: transfer == .full, constOut: constOut)
case .filename: case .filename:
return .filenameMapping(free: transfer == .full) let constOut = cType.isEmpty || cType.hasPrefix("const")
return .filenameMapping(free: transfer == .full, constOut: constOut)
case .pointer: case .pointer:
return .pointerMapping return .pointerMapping
case .vaList: case .vaList:
@ -234,6 +244,20 @@ private func mapTypeRef(
detail: "unresolved type '\(namespace).\(name)'") detail: "unresolved type '\(namespace).\(name)'")
} }
// A type DECLARED in some module is dropped by the planner
// (SkipReason.duplicateOfDependency, see Planner.skipIfDuplicate) when a
// dependency of that module already declares a type of the same simple
// name (e.g. Gdk.Rectangle vs. Pango.Rectangle) keeping both would make
// unqualified cross-module references ambiguous downstream. Any
// callable/property/signal/field that references the dropped type must
// skip too, or it emits a reference to a type that was never generated.
// `TypeRegistry.droppedShadow` computes this once (namespace-agnostic),
// so this catches both same-namespace and cross-module references to a
// dropped declaration.
if let dep = context.registry.droppedShadow(resolved.girName) {
throw MapError(reason: .duplicateOfDependency,
detail: "'\(resolved.girName)' was dropped as a duplicate of dependency module '\(dep)'")
}
switch resolved.category { switch resolved.category {
case .foreign(let foreignNS): case .foreign(let foreignNS):
throw MapError(reason: .foreignNamespace, throw MapError(reason: .foreignNamespace,
@ -457,18 +481,18 @@ extension Mapping {
gvalue: GValueOps(typeMacro: "G_TYPE_POINTER", gvalue: GValueOps(typeMacro: "G_TYPE_POINTER",
getterSuffix: "pointer", setterSuffix: "pointer")) getterSuffix: "pointer", setterSuffix: "pointer"))
static func stringMapping(free: Bool) -> Mapping { static func stringMapping(free: Bool, constOut: Bool = true) -> Mapping {
Mapping( Mapping(
swiftType: "String", cSwiftType: "UnsafePointer<CChar>?", swiftType: "String", cSwiftType: "UnsafePointer<CChar>?",
marshalIn: .stringToC, marshalOut: .stringCopy(free: free), marshalIn: .stringToC, marshalOut: .stringCopy(free: free, constPointee: constOut),
gvalue: GValueOps(typeMacro: "G_TYPE_STRING", gvalue: GValueOps(typeMacro: "G_TYPE_STRING",
getterSuffix: "string", setterSuffix: "string")) getterSuffix: "string", setterSuffix: "string"))
} }
static func filenameMapping(free: Bool) -> Mapping { static func filenameMapping(free: Bool, constOut: Bool = true) -> Mapping {
Mapping( Mapping(
swiftType: "String", cSwiftType: "UnsafePointer<CChar>?", swiftType: "String", cSwiftType: "UnsafePointer<CChar>?",
marshalIn: .stringToC, marshalOut: .stringCopy(free: free)) marshalIn: .stringToC, marshalOut: .stringCopy(free: free, constPointee: constOut))
} }
} }

View file

@ -138,6 +138,18 @@ public struct TypeRegistry: Sendable {
private let foreignNamespaces: Set<String> private let foreignNamespaces: Set<String>
/// Maps a GIR namespace name to its Swift module name. /// Maps a GIR namespace name to its Swift module name.
private let namespaceToModule: [String: String] private let namespaceToModule: [String: String]
/// Swift simple type names declared by two or more distinct modules
/// (e.g. `Matrix` in both `Graphene` and `Pango`) ambiguous wherever
/// referenced from a module other than the declaring one, since every
/// module `@_exported import`s its full dependency closure. See
/// `swiftTypeName(for:in:)`.
private var ambiguousSwiftNames: Set<String> = []
/// Maps a dropped type's fully-qualified GIR name to the dependency
/// module that shadows it (the same predicate `Planner.skipIfDuplicate`
/// uses at declaration time), computed once so declaration-time and
/// reference-time drop checks share a single source of truth. See
/// ``droppedShadow(_:)``.
private var droppedGIRNames: [String: String] = [:]
/// The fully qualified name of the GObject root class. /// The fully qualified name of the GObject root class.
public static let objectGIRName = "GObject.Object" public static let objectGIRName = "GObject.Object"
@ -150,12 +162,18 @@ public struct TypeRegistry: Sendable {
/// Namespaces referenced via `<include>` but absent from `repositories` /// Namespaces referenced via `<include>` but absent from `repositories`
/// are recorded as foreign, so references into them resolve to /// are recorded as foreign, so references into them resolve to
/// ``TypeCategory/foreign(namespace:)`` rather than failing. /// ``TypeCategory/foreign(namespace:)`` rather than failing.
///
/// - Parameters: /// - Parameters:
/// - repositories: Parsed repositories keyed by Swift module name. /// - repositories: Parsed repositories keyed by Swift module name.
/// - directDependencies: Each module's direct dependency module
/// names, used to compute ``droppedShadow(_:)``. Defaults to empty
/// (no drop detection) for callers that don't need it.
/// - manualNamespaces: Namespaces deliberately excluded from generation /// - manualNamespaces: Namespaces deliberately excluded from generation
/// and treated as foreign even when a GIR is present. Defaults to empty. /// and treated as foreign even when a GIR is present. Defaults to empty.
public init(repositories: [String: Repository], manualNamespaces: Set<String> = []) { public init(
repositories: [String: Repository],
directDependencies: [String: Set<String>] = [:],
manualNamespaces: Set<String> = []
) {
var namespaceToModule: [String: String] = [:] var namespaceToModule: [String: String] = [:]
for (module, repo) in repositories { for (module, repo) in repositories {
for ns in repo.namespaces { for ns in repo.namespaces {
@ -178,6 +196,51 @@ public struct TypeRegistry: Sendable {
register(namespace: ns, module: module) register(namespace: ns, module: module)
} }
} }
// A module's public API is flattened into every downstream consumer
// via `@_exported import` (the umbrella "import Gsk gets you GLib,
// GObject, Gdk, Graphene, Pango, ..." convenience) including
// sideways, between two modules that don't depend on each other but
// share a downstream dependent (e.g. Graphene.Matrix and
// Pango.Matrix both reach Gsk). The existing declaration-time
// `duplicateOfDependency` skip only catches a direct dependency
// edge; it can't see this diamond. Any Swift simple name declared
// by two or more distinct modules is therefore permanently
// ambiguous wherever it's referenced from outside its own
// declaring module, and must be module-qualified there.
var modulesByName: [String: Set<String>] = [:]
for resolved in types.values {
modulesByName[resolved.swiftName, default: []].insert(resolved.swiftModule)
}
self.ambiguousSwiftNames = Set(modulesByName.filter { $0.value.count > 1 }.keys)
// A type is dropped as a duplicate when a dependency of its own
// declaring module already declares a type of the same simple name
// (mirrors `Planner.duplicateDependencyModule`). Namespace-agnostic:
// any reference to the dropped GIR name is caught regardless of
// which namespace the reference itself was written in.
var dropped: [String: String] = [:]
for (girName, resolved) in types {
guard let dot = girName.lastIndex(of: ".") else { continue }
let simpleName = String(girName[girName.index(after: dot)...])
for dep in directDependencies[resolved.swiftModule] ?? [] {
if resolve(name: simpleName, namespace: dep) != nil {
dropped[girName] = dep
break
}
}
}
self.droppedGIRNames = dropped
}
/// Returns the dependency module that shadows `girName`, if the
/// declaration was dropped as a duplicate (see ``init(repositories:directDependencies:manualNamespaces:)``).
///
/// - Parameter girName: A fully qualified GIR name, e.g. `"Gdk.Rectangle"`.
/// - Returns: The shadowing dependency module name, or `nil` if `girName`
/// was not dropped.
public func droppedShadow(_ girName: String) -> String? {
droppedGIRNames[girName]
} }
/// Registers every type in one namespace. /// Registers every type in one namespace.
@ -395,15 +458,28 @@ public struct TypeRegistry: Sendable {
/// The Swift spelling of a resolved type as written from `module`. /// The Swift spelling of a resolved type as written from `module`.
/// ///
/// Types from another module are qualified (`GObject.Object`); types from /// Bare (`Widget`) in the overwhelmingly common case. Every module
/// the current module are bare (`Widget`). /// `@_exported import`s its full dependency closure (so `import Gsk`
/// alone reaches `GLib`/`GObject`/`Gdk`/`Graphene`/`Pango`/...), which
/// flattens all those modules' public API into one lookup scope for any
/// file that imports one of them including sideways, between two
/// modules that don't depend on each other but share a downstream
/// dependent (`Graphene.Matrix` and `Pango.Matrix` both reach `Gsk`).
/// A Swift simple name declared by more than one loaded module is
/// therefore genuinely ambiguous outside its own declaring module, and
/// is qualified (`Graphene.Matrix`) to disambiguate; same-module
/// self-references stay bare regardless (Swift shadowing rules mean a
/// module's own declarations are never ambiguous with themselves).
/// ///
/// - Parameters: /// - Parameters:
/// - type: The resolved type to spell. /// - type: The resolved type to spell.
/// - module: The Swift module the reference is being written in. /// - module: The Swift module the reference is being written in.
/// - Returns: The Swift type name to emit. /// - Returns: The Swift type name to emit.
public func swiftTypeName(for type: ResolvedType, in module: String) -> String { public func swiftTypeName(for type: ResolvedType, in module: String) -> String {
type.swiftName guard type.swiftModule != module, ambiguousSwiftNames.contains(type.swiftName) else {
return type.swiftName
}
return "\(type.swiftModule).\(type.swiftName)"
} }
/// Every class that is subclassed by some other loaded class. /// Every class that is subclassed by some other loaded class.

View file

@ -222,7 +222,14 @@ final class GIRXMLDelegate: NSObject, XMLParserDelegate {
stack.append(.ignored(elementName)) stack.append(.ignored(elementName))
case "record": case "record":
guard let name = requireAttribute("name", from: attributeDict, for: elementName, parser: parser) else { return } guard let name = attributeDict["name"] else {
// Anonymous nested <record> (a C struct inside a <union>, e.g.
// GskPathPoint). Not a namespace-level type; discard it and its
// fields record fields are deferred and the parent union is
// already .ignored.
stack.append(.ignored(elementName))
return
}
stack.append(.record(Record( stack.append(.record(Record(
name: name, name: name,
cType: attributeDict["c:type"] ?? "", cType: attributeDict["c:type"] ?? "",

View file

@ -38,7 +38,7 @@ struct SwiftGtkGenCLI {
let outputRoot = URL(fileURLWithPath: monorepoConfig.outputDir) let outputRoot = URL(fileURLWithPath: monorepoConfig.outputDir)
// Plan engine (the only engine): build registry, plan, render. // Plan engine (the only engine): build registry, plan, render.
let registry = TypeRegistry(repositories: analysis.repositories) let registry = TypeRegistry(repositories: analysis.repositories, directDependencies: analysis.directDependencies)
let modulePlans = planModules(analysis: analysis, registry: registry) let modulePlans = planModules(analysis: analysis, registry: registry)
var outputs: [String: [String: String]] = [:] var outputs: [String: [String: String]] = [:]

View file

@ -47,9 +47,12 @@ struct NamingTests {
#expect(!isValidGeneratedFileName("boxedFree.swift")) // lowerCamelCase #expect(!isValidGeneratedFileName("boxedFree.swift")) // lowerCamelCase
#expect(!isValidGeneratedFileName("PARAM_MASK.swift")) // SCREAMING_SNAKE #expect(!isValidGeneratedFileName("PARAM_MASK.swift")) // SCREAMING_SNAKE
#expect(!isValidGeneratedFileName("CSET_a_2_z.swift")) // underscores #expect(!isValidGeneratedFileName("CSET_a_2_z.swift")) // underscores
#expect(!isValidGeneratedFileName("cclosureMarshalBOOLEANFLAGS.swift")) // uppercase run #expect(!isValidGeneratedFileName("cclosureMarshalBOOLEANFLAGS.swift")) // uppercase run, lowercase start
#expect(!isValidGeneratedFileName("MarshalBOOLEAN.swift")) // run > 3 caps #expect(isValidGeneratedFileName("RGBA.swift")) // acronym type name
#expect(isValidGeneratedFileName("GLAPI.swift")) // acronym type name
#expect(isValidGeneratedFileName("DNDEvent.swift")) // acronym type name
#expect(!isValidGeneratedFileName("Align.txt")) // wrong extension #expect(!isValidGeneratedFileName("Align.txt")) // wrong extension
#expect(!isValidGeneratedFileName("MarshalBOOLEAN.swift")) // uppercase run, PascalCase start
} }
// MARK: - File merging // MARK: - File merging

View file

@ -81,6 +81,9 @@ struct RecordGenerationTests {
#expect(source.contains("g_variant_type_copy(_instancePointer(pointer))")) #expect(source.contains("g_variant_type_copy(_instancePointer(pointer))"))
#expect(source.contains("isolated deinit {")) #expect(source.contains("isolated deinit {"))
#expect(source.contains("g_variant_type_free(_instancePointer(pointer))")) #expect(source.contains("g_variant_type_free(_instancePointer(pointer))"))
// Records with a free function claim to take responsibility for freeing.
#expect(source.contains("takes responsibility"))
#expect(!source.contains("never frees"))
} }
@Test("A record with no resolvable free renders no deinit") @Test("A record with no resolvable free renders no deinit")
@ -91,6 +94,9 @@ struct RecordGenerationTests {
#expect(!source.contains("retaining")) #expect(!source.contains("retaining"))
// The takingOwnership init is always present. // The takingOwnership init is always present.
#expect(source.contains("takingOwnership")) #expect(source.contains("takingOwnership"))
// No-free records must not claim to free the pointee.
#expect(source.contains("never frees"))
#expect(!source.contains("takes responsibility"))
} }
// MARK: - C4 copy/free pairing // MARK: - C4 copy/free pairing

View file

@ -96,6 +96,36 @@ struct RendererCallableTests {
#expect(body.contains("func getCoords(id: Int32) -> (x: Int32, y: Int32)")) #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") @Test("Swift return with out-params produces a labeled tuple")
func returnWithOutParamsLabeledTuple() throws { func returnWithOutParamsLabeledTuple() throws {
let fn = GlobalFunction( let fn = GlobalFunction(

View file

@ -30,6 +30,34 @@ struct SignalGenerationTests {
return MapContext(registry: registry, currentModule: "GObject", currentNamespace: "GObject") return MapContext(registry: registry, currentModule: "GObject", currentNamespace: "GObject")
} }
/// A context whose registry additionally declares two boxed records: one
/// with a copy function (wraps via `init(retaining:)`), one without
/// (wraps via `init(takingOwnership:)`, mirroring `GdkToplevelSize`).
func makeBoxedContext() -> MapContext {
let gobject = Repository(namespaces: [
Namespace(
name: "GObject", version: "2.0",
records: [
Record(name: "Value", cType: "GValue", getTypeFunction: "g_value_get_type",
copyFunction: "g_value_copy", freeFunction: "g_value_free"),
Record(name: "NoCopyBox", cType: "GNoCopyBox", getTypeFunction: "g_no_copy_box_get_type"),
]
)
])
let registry = TypeRegistry(repositories: ["GObject": gobject])
return MapContext(registry: registry, currentModule: "GObject", currentNamespace: "GObject")
}
func renderClass(named name: String, signals: [Signal], context: MapContext) -> (source: String, plan: ClassPlan, skips: [SkipEntry]) {
let klass = Class(name: name, cType: "G\(name)", parent: nil,
getTypeFunction: "g_\(name.lowercased())_get_type",
signals: signals)
let (plan, skips) = planClass(klass, context: context)
let module = ModulePlan(module: "GObject", types: [.class(plan)], skips: skips,
coverage: CoverageStats())
return (renderModule(module)["\(name).swift"] ?? "", plan, skips)
}
/// Plans a one-off class carrying `signals` and renders it, returning the /// Plans a one-off class carrying `signals` and renders it, returning the
/// class file body plus the plan and skips. /// class file body plus the plan and skips.
func renderClass(named name: String, signals: [Signal]) -> (source: String, plan: ClassPlan, skips: [SkipEntry]) { func renderClass(named name: String, signals: [Signal]) -> (source: String, plan: ClassPlan, skips: [SkipEntry]) {
@ -102,4 +130,23 @@ struct SignalGenerationTests {
#expect(source.contains("_sgtk_signal_connect_data(")) #expect(source.contains("_sgtk_signal_connect_data("))
#expect(!source.contains("_sgtk_signal_connect_data(ptr, cName, unsafeBitCast(\\(plan.trampolineCName)")) #expect(!source.contains("_sgtk_signal_connect_data(ptr, cName, unsafeBitCast(\\(plan.trampolineCName)"))
} }
@Test("Boxed signal param wraps via retaining: when a copy function exists, takingOwnership: when it doesn't")
func boxedSignalParamWrapperSelection() throws {
let withCopy = Signal(
name: "value-changed",
parameters: [Parameter(name: "value", type: .typeRef("Value", namespace: "GObject"),
cType: "GValue*")]
)
let withoutCopy = Signal(
name: "box-changed",
parameters: [Parameter(name: "box", type: .typeRef("NoCopyBox", namespace: "GObject"),
cType: "GNoCopyBox*")]
)
let (source, plan, skips) = renderClass(named: "Emitter", signals: [withCopy, withoutCopy], context: makeBoxedContext())
#expect(skips.isEmpty)
#expect(plan.signals.count == 2)
#expect(source.contains("Value(retaining:"))
#expect(source.contains("NoCopyBox(takingOwnership:"))
#expect(!source.contains("NoCopyBox(retaining:"))
}
} }

View file

@ -255,6 +255,27 @@ struct TypeMapperTests {
#expect(mapping.marshalOut == .stringCopy(free: false)) #expect(mapping.marshalOut == .stringCopy(free: false))
} }
@Test("String out-param constPointee follows the c:type, not transfer")
func stringConstPointeeFollowsCType() throws {
let ctx = makeContext()
// `const gchar**`: pointee is borrowed, marshal must not free through
// a mutable pointer.
let constMapping = try map(.string, nullable: false, transfer: .none,
context: ctx, cType: "const gchar**")
#expect(constMapping.marshalOut == .stringCopy(free: false, constPointee: true))
// `gchar**`: pointee is mutable/owned.
let mutableMapping = try map(.string, nullable: false, transfer: .none,
context: ctx, cType: "gchar**")
#expect(mutableMapping.marshalOut == .stringCopy(free: false, constPointee: false))
// No c:type given: defaults to const (the conservative choice).
let defaultMapping = try map(.string, nullable: false, transfer: .none, context: ctx)
#expect(defaultMapping.marshalOut == .stringCopy(free: false, constPointee: true))
// .filename follows the same rule.
let filenameMapping = try map(.filename, nullable: false, transfer: .none,
context: ctx, cType: "gchar**")
#expect(filenameMapping.marshalOut == .stringCopy(free: false, constPointee: false))
}
// MARK: - Nullable wrapping // MARK: - Nullable wrapping
@Test("Nullable string becomes Optional") @Test("Nullable string becomes Optional")

View file

@ -172,6 +172,67 @@ struct TypeRegistryTests {
#expect(registry.swiftTypeName(for: widget, in: "Gtk") == "Widget") #expect(registry.swiftTypeName(for: widget, in: "Gtk") == "Widget")
} }
@Test("A simple name declared by two sibling modules is qualified outside its own module")
func qualifiesAmbiguousSwiftNames() throws {
// Graphene and Pango both declare `Matrix`; neither depends on the
// other, but Gsk (downstream) reaches both via @_exported import.
let graphene = Repository(namespaces: [
Namespace(name: "Graphene", version: "1.0",
records: [Record(name: "Matrix", cType: "graphene_matrix_t",
getTypeFunction: "graphene_matrix_get_type")])
])
let pango = Repository(namespaces: [
Namespace(name: "Pango", version: "1.0",
records: [
Record(name: "Matrix", cType: "PangoMatrix",
getTypeFunction: "pango_matrix_get_type"),
Record(name: "Rectangle", cType: "PangoRectangle",
getTypeFunction: "pango_rectangle_get_type"),
])
])
let registry = TypeRegistry(repositories: ["Graphene": graphene, "Pango": pango])
let grapheneMatrix = try #require(registry.resolve(girName: "Graphene.Matrix"))
let uniqueType = try #require(registry.resolve(girName: "Pango.Rectangle"))
#expect(registry.swiftTypeName(for: grapheneMatrix, in: "Gsk") == "Graphene.Matrix")
#expect(registry.swiftTypeName(for: uniqueType, in: "Gsk") == "Rectangle")
// Same-module self-reference always stays bare, even though the
// name is ambiguous elsewhere.
#expect(registry.swiftTypeName(for: grapheneMatrix, in: "Graphene") == "Matrix")
}
@Test("A type dropped as a duplicate of a dependency is detected regardless of the referencing namespace")
func detectsCrossModuleDroppedType() {
// Dep declares Rect; Mid (which depends on Dep) redeclares the same
// simple name the planner drops Mid.Rect as a duplicate. A
// reference into Mid.Rect from any namespace, not just Mid's own,
// must be recognized as dangling.
let dep = Repository(namespaces: [
Namespace(name: "Dep", version: "1.0",
records: [Record(name: "Rect", cType: "DepRect", getTypeFunction: "dep_rect_get_type")])
])
let mid = Repository(namespaces: [
Namespace(name: "Mid", version: "1.0",
records: [Record(name: "Rect", cType: "MidRect", getTypeFunction: "mid_rect_get_type")])
])
let registry = TypeRegistry(
repositories: ["Dep": dep, "Mid": mid],
directDependencies: ["Mid": ["Dep"]]
)
#expect(registry.droppedShadow("Mid.Rect") == "Dep")
#expect(registry.droppedShadow("Dep.Rect") == nil)
let context = MapContext(registry: registry, currentModule: "Down", currentNamespace: "Down",
dependencyModules: ["Dep", "Mid"])
do {
_ = try map(.typeRef("Rect", namespace: "Mid"), nullable: false, transfer: .none, context: context)
Issue.record("expected reference to dropped Mid.Rect to throw")
} catch let err as MapError {
#expect(err.reason == .duplicateOfDependency)
} catch {
Issue.record("unexpected error type: \(error)")
}
}
@Test("Manually excluded namespaces are treated as foreign") @Test("Manually excluded namespaces are treated as foreign")
func manualNamespacesAreForeign() { func manualNamespacesAreForeign() {
let repo = Repository(namespaces: [ let repo = Repository(namespaces: [

View file

@ -0,0 +1,39 @@
// GdkSmoke.swift
// Tier-4-only runtime smoke test for Gdk (Phase E3). Proves against the
// REAL libgtk-4, linked at runtime that:
// 1. `keyvalFromName(keyvalName:)` reaches the real `gdk_keyval_from_name`
// C call, marshalling a Swift `String` through `withCString` and the
// returned `guint` keyval back as `UInt32`.
// 2. `keyvalName(keyval:)` reaches the real `gdk_keyval_name`, marshalling
// the `guint` argument and the returned `const gchar*` back into a
// Swift `String?` a round trip through both keyval accessors.
//
// Both are display-free: no `GdkDisplay`/surface is created, so this runs
// without a windowing backend.
//
// Not generated. `scripts/smoke-test.sh <tier>` copies every `smoke/*.swift`
// plus `smoke/tier<N>/*.swift` into the generated SmokeTests target before
// running `swift test`. Only installed for tier >= 4 (Gdk's module).
import Testing
import GLib
import GObject
import Gdk
@Suite("Tier 4 Gdk smoke tests")
struct GdkSmokeTests {
@Test("keyvalFromName(keyvalName:) reaches the real gdk_keyval_from_name and reports the known GDK_KEY_a value")
func keyvalFromNameReturnsKnownKeyval() throws {
let keyval = keyvalFromName(keyvalName: "a")
// GDK_KEY_a is a fixed constant (0x61, matching ASCII 'a').
#expect(keyval == 0x61)
}
@Test("keyvalName(keyval:) round-trips the value keyvalFromName reports back to the original name")
func keyvalRoundTrips() throws {
let keyval = keyvalFromName(keyvalName: "a")
let name = keyvalName(keyval: keyval)
#expect(name == "a")
}
}

View file

@ -0,0 +1,38 @@
// GskSmoke.swift
// Tier-4-only runtime smoke test for Gsk (Phase E3). Proves against the
// REAL libgtk-4, linked at runtime that `CairoRenderer()` reaches the real
// `gsk_cairo_renderer_new` C constructor and adopts the returned pointer
// through `init(takingOwnership:)`, exercising the Gsk GObject-class
// construction path end to end.
//
// `CairoRenderer` is display-free to construct (it only needs a `GdkSurface`
// at `realize()` time, which this test does not call), so this runs without
// a windowing backend.
//
// `gsk_serialization_error_quark` the other display-free candidate this
// tier considered is deprecated/moved-to `SerializationError.quark` in the
// GIR and the nested enum-scoped function is not yet planned by the
// generator (absent from docs/skip-baseline/tier4/Gsk.json's bound set), so
// `CairoRenderer()` alone carries the Gsk runtime-coverage requirement here.
//
// Not generated. `scripts/smoke-test.sh <tier>` copies every `smoke/*.swift`
// plus `smoke/tier<N>/*.swift` into the generated SmokeTests target before
// running `swift test`. Only installed for tier >= 4 (Gsk's module).
import Testing
import GLib
import GObject
import Gdk
import Gsk
@Suite("Tier 4 Gsk smoke tests")
struct GskSmokeTests {
@Test("CairoRenderer() reaches the real gsk_cairo_renderer_new and constructs a live GObject")
func cairoRendererConstructs() throws {
let renderer = CairoRenderer()
// `isRealized()` is a plain GObject property read (no surface needed):
// a freshly constructed renderer must never report itself realized.
#expect(renderer.isRealized() == false)
}
}