1
0
Fork 0

Generate class methods and constructors with runtime smoke tests

This commit is contained in:
Brendan Szymanski 2026-07-17 15:43:30 -04:00
parent 56de5bc976
commit 144da7860e
9 changed files with 673 additions and 115 deletions

View file

@ -122,7 +122,7 @@ extension CodeGenerator {
///
/// - Parameter analysis: The resolved multi-package analysis.
/// - Returns: A dictionary of relative path file content.
public static func generateMonorepoScaffolding(analysis: MultiPackageAnalysis) -> [String: String] {
public static func generateMonorepoScaffolding(analysis: MultiPackageAnalysis, includeSmokeTarget: Bool = false) -> [String: String] {
var files: [String: String] = [:]
// Re-export umbrella files
@ -134,32 +134,20 @@ extension CodeGenerator {
// C bridge files
for (moduleName, repo) in analysis.repositories {
let cName = "C\(moduleName)"
let ns = repo.namespaces.first { $0.name == moduleName } ?? repo.namespaces.first
let sharedLibrary = ns?.cSharedLibrary ?? ""
let libraries = sharedLibrary.split(separator: ",").map(String.init)
let allLinkNames = libraries.compactMap { extractLinkName(from: $0) }
let lowerModName = moduleName.lowercased()
let cHeader = repo.cHeaderPath
let umbrellaHeader = cHeader.isEmpty
? "#include <\(lowerModName)/\(lowerModName).h>"
: "#include <\(cHeader)>"
// External C link names from GIR includes
var externalLinks: [String] = []
for include in repo.includedPackages {
let major = include.version.split(separator: ".").first.map(String.init) ?? include.version
let el = "\(include.name.lowercased())-\(major)"
if !externalLinks.contains(el) {
externalLinks.append(el)
}
}
let linkLines = (allLinkNames + externalLinks).map { " link \"\($0)\"" }.joined(separator: "\n")
// No `link` directives: the `.systemLibrary` target carries
// `pkgConfig:`, so pkg-config `--libs` supplies the exact linker
// flags. Emitting `link "glib-2"` here both duplicates that and
// gets the name wrong (the library is `glib-2.0`), which breaks
// linking of any executable (e.g. the smoke-test runner).
let moduleMap = """
module \(cName) [system] {
header "\(cName).h"
\(linkLines)
}
"""
files["Sources/\(cName)/module.modulemap"] = moduleMap
@ -167,14 +155,23 @@ extension CodeGenerator {
}
// Monorepo Package.swift
let packageSwift = generateMonorepoPackage(analysis: analysis)
let packageSwift = generateMonorepoPackage(analysis: analysis, includeSmokeTarget: includeSmokeTarget)
files["Package.swift"] = packageSwift
return files
}
/// Extracts link name from a shared-library string like "libgtk-4.so.1" -> "gtk-4".
/// Used by the single-GIR (`generatePackageScaffolding`) path only.
private static func extractLinkName(from sharedLib: String) -> String? {
guard sharedLib.hasPrefix("lib") else { return nil }
let withoutLib = String(sharedLib.dropFirst(3))
guard let soRange = withoutLib.range(of: ".so") else { return withoutLib }
return String(withoutLib[..<soRange.lowerBound])
}
/// Generates the single monorepo Package.swift containing all targets.
private static func generateMonorepoPackage(analysis: MultiPackageAnalysis) -> String {
private static func generateMonorepoPackage(analysis: MultiPackageAnalysis, includeSmokeTarget: Bool = false) -> String {
let moduleNames = analysis.repositories.keys.sorted()
// Products
@ -213,6 +210,23 @@ extension CodeGenerator {
"""
}
// Optional smoke-test target depending on every generated module, used
// by scripts/smoke-test.sh to exercise the bindings against the real C
// libraries at runtime.
var smokeTarget = ""
if includeSmokeTarget {
let deps = moduleNames.map { "\"\($0)\"" }.joined(separator: ", ")
smokeTarget = """
.testTarget(
name: "SmokeTests",
dependencies: [\(deps)],
path: "Tests/SmokeTests",
swiftSettings: swiftSettings
),
"""
}
return """
// swift-tools-version: 6.2
import PackageDescription
@ -229,17 +243,10 @@ extension CodeGenerator {
\(products) ],
targets: [
\(cTargets)
\(swiftTargets) ]
\(swiftTargets)\(smokeTarget) ]
)
"""
}
/// Extracts link name from a shared-library string like "libgtk-4.so.1" -> "gtk-4".
private static func extractLinkName(from sharedLib: String) -> String? {
guard sharedLib.hasPrefix("lib") else { return nil }
let withoutLib = String(sharedLib.dropFirst(3))
guard let soRange = withoutLib.range(of: ".so") else { return withoutLib }
return String(withoutLib[..<soRange.lowerBound])
}
}

View file

@ -34,9 +34,43 @@ public func renderModule(_ plan: ModulePlan) -> [String: String] {
files["\(baseName).swift"] = content
}
// Emit the per-module pointer-cast support only when at least one class is
// present (methods/constructors reference `_instancePointer`).
let hasClass = plan.types.contains { if case .class = $0 { return true } else { return false } }
if hasClass {
files["_Support.swift"] = renderSupport()
}
return files
}
/// Renders the per-module support file: the overloaded `_instancePointer`
/// helper that reinterprets a wrapper's raw `pointer` as the specific C pointer
/// type each C call expects. Two overloads let call-site overload resolution
/// pick `OpaquePointer` (opaque C structs) or `UnsafeMutablePointer<T>`
/// (complete C structs) without the generator needing to know which a type is.
private func renderSupport() -> String {
"""
// Generated by SwiftGtkGen. DO NOT EDIT.
/// Reinterprets a wrapper's raw instance pointer as an `OpaquePointer`,
/// selected when the C function's parameter is an opaque struct pointer.
@inline(__always)
func _instancePointer(_ pointer: UnsafeMutableRawPointer) -> OpaquePointer {
OpaquePointer(pointer)
}
/// Reinterprets a wrapper's raw instance pointer as a typed
/// `UnsafeMutablePointer<T>`, selected when the C function's parameter is a
/// complete struct pointer.
@inline(__always)
func _instancePointer<T>(_ pointer: UnsafeMutableRawPointer) -> UnsafeMutablePointer<T> {
pointer.assumingMemoryBound(to: T.self)
}
"""
}
// MARK: - Type-level rendering
/// Renders a single `TypePlan` into a base filename and Swift source body.
@ -244,33 +278,44 @@ private func renderClass(_ plan: ClassPlan) -> String {
// already released its reference. Proper ref-counting requires
// tracking transfer ownership from every C call path.
// Members: constructors, methods, static functions
for ctor in plan.constructors {
lines.append(contentsOf: renderConstructor(ctor))
lines.append("")
}
for method in plan.methods {
lines.append(contentsOf: renderMethod(method))
lines.append("")
}
for fn in plan.functions {
lines.append(contentsOf: renderStaticFunction(fn))
lines.append("")
}
lines.append("}")
return lines.joined(separator: "\n") + "\n"
}
// Callable (function)
private func renderCallable(_ plan: CallablePlan) -> String {
var lines: [String] = []
if let doc = plan.doc {
lines.append(contentsOf: renderDocComment(doc))
}
// Build parameter list for Swift signature
let swiftParams = plan.parameters.filter { !$0.isInstanceParameter }.map { param in
/// The Swift parameter list for a callable (excludes the instance parameter).
private func swiftSignature(_ plan: CallablePlan) -> String {
plan.parameters.filter { !$0.isInstanceParameter }.map { param in
"\(param.swiftName): \(param.mapping.swiftType)"
}.joined(separator: ", ")
}
// Return type
let returnDecl = plan.returnMapping.map { " -> \($0.swiftType)" } ?? ""
// Assign a C-string local to each string parameter; those are bridged
// through `withCString`, so the C argument is the closure's pointer rather
// than the Swift `String`.
/// Builds the C call: the C-function-call string with each argument marshalled,
/// plus the string parameters that must be wrapped in `withCString`. The
/// instance parameter if any is passed as `self.pointer`.
private func cArguments(_ plan: CallablePlan) -> (cCall: String, stringParams: [(cName: String, swiftName: String)]) {
var stringParams: [(cName: String, swiftName: String)] = []
let cArgExprs: [String] = plan.parameters.map { param in
if param.isInstanceParameter { return "self.pointer" }
// The C method's instance parameter is a typed pointer (`GObject *`,
// etc.) or `OpaquePointer` for opaque structs. `_instancePointer`'s
// two overloads let call-site resolution pick the right one from our
// raw `self.pointer`.
if param.isInstanceParameter { return "_instancePointer(self.pointer)" }
if param.mapping.marshalIn == .stringToC {
let cName = "cString\(stringParams.count)"
stringParams.append((cName: cName, swiftName: param.swiftName))
@ -278,37 +323,95 @@ private func renderCallable(_ plan: CallablePlan) -> String {
}
return marshalCallArg(param)
}
return ("\(plan.cIdentifier)(\(cArgExprs.joined(separator: ", ")))", stringParams)
}
let cCall = "\(plan.cIdentifier)(\(cArgExprs.joined(separator: ", ")))"
/// Builds a single Swift expression that evaluates to the callable's raw C
/// return value, bridging string parameters through nested `withCString`
/// closures. Used where a statement form is impossible (e.g. feeding a
/// constructor's `self.init`). Prefer `renderCallBody` for functions/methods
/// the multi-line statement form keeps the type-checker happy for callables
/// with several string parameters.
private func renderCallExpression(_ plan: CallablePlan) -> String {
let (cCall, stringParams) = cArguments(plan)
var expr = cCall
for sp in stringParams.reversed() {
expr = "\(sp.swiftName).withCString { \(sp.cName) in \(expr) }"
}
return expr
}
// The statement evaluated in the innermost scope.
/// Renders the `{ }` body lines of a function or method (indented by
/// `indent`). String parameters are wrapped in one `withCString` closure per
/// string, one level per line, each returning its inner result the flat
/// single-expression form overwhelms the type-checker past ~2 nested closures.
private func renderCallBody(_ plan: CallablePlan, indent: String) -> [String] {
let (cCall, stringParams) = cArguments(plan)
let hasReturn = plan.returnMapping != nil
let core = hasReturn ? "return \(marshalReturn(cCall, mapping: plan.returnMapping!))" : cCall
lines.append("public func \(plan.name)(\(swiftParams))\(returnDecl) {")
if stringParams.isEmpty { return ["\(indent)\(core)"] }
if stringParams.isEmpty {
lines.append(" \(core)")
} else {
// Wrap the call in one `withCString` closure per string parameter.
// With a return value, each level returns its inner closure's result.
var indent = " "
let openerPrefix = hasReturn ? "return " : ""
for sp in stringParams {
lines.append("\(indent)\(openerPrefix)\(sp.swiftName).withCString { \(sp.cName) in")
indent += " "
}
lines.append("\(indent)\(core)")
for _ in stringParams {
indent = String(indent.dropLast(4))
lines.append("\(indent)}")
}
var lines: [String] = []
var scope = indent
let openerPrefix = hasReturn ? "return " : ""
for sp in stringParams {
lines.append("\(scope)\(openerPrefix)\(sp.swiftName).withCString { \(sp.cName) in")
scope += " "
}
lines.append("\(scope)\(core)")
for _ in stringParams {
scope = String(scope.dropLast(4))
lines.append("\(scope)}")
}
return lines
}
private func renderCallable(_ plan: CallablePlan) -> String {
var lines: [String] = []
if let doc = plan.doc { lines.append(contentsOf: renderDocComment(doc)) }
let returnDecl = plan.returnMapping.map { " -> \($0.swiftType)" } ?? ""
lines.append("public func \(plan.name)(\(swiftSignature(plan)))\(returnDecl) {")
lines.append(contentsOf: renderCallBody(plan, indent: " "))
lines.append("}")
return lines.joined(separator: "\n") + "\n"
}
/// Renders an instance method inside a class body (4-space indented).
private func renderMethod(_ plan: CallablePlan) -> [String] {
var lines: [String] = []
if let doc = plan.doc { lines.append(contentsOf: renderDocComment(doc).map { " \($0)" }) }
let returnDecl = plan.returnMapping.map { " -> \($0.swiftType)" } ?? ""
lines.append(" public func \(plan.name)(\(swiftSignature(plan)))\(returnDecl) {")
lines.append(contentsOf: renderCallBody(plan, indent: " "))
lines.append(" }")
return lines
}
/// Renders a static (class-level) function inside a class body.
private func renderStaticFunction(_ plan: CallablePlan) -> [String] {
var lines: [String] = []
if let doc = plan.doc { lines.append(contentsOf: renderDocComment(doc).map { " \($0)" }) }
let returnDecl = plan.returnMapping.map { " -> \($0.swiftType)" } ?? ""
lines.append(" public static func \(plan.name)(\(swiftSignature(plan)))\(returnDecl) {")
lines.append(contentsOf: renderCallBody(plan, indent: " "))
lines.append(" }")
return lines
}
/// Renders a constructor as a `convenience init`. The C constructor's returned
/// instance pointer is adopted through the designated `init(takingOwnership:)`,
/// which sinks a floating reference for `InitiallyUnowned` descendants.
private func renderConstructor(_ plan: CallablePlan) -> [String] {
var lines: [String] = []
if let doc = plan.doc { lines.append(contentsOf: renderDocComment(doc).map { " \($0)" }) }
let expr = renderCallExpression(plan)
lines.append(" public convenience init(\(swiftSignature(plan))) {")
lines.append(" self.init(takingOwnership: UnsafeMutableRawPointer(\(expr)))")
lines.append(" }")
return lines
}
/// Generates the argument expression for a callable parameter.
private func marshalCallArg(_ param: ParameterPlan) -> String {
switch param.mapping.marshalIn {

View file

@ -74,7 +74,10 @@ private func planNamespace(_ ns: Namespace, context: MapContext) -> ModulePlan {
// Classes, Interfaces, Records, Callbacks skip with reasons (Phase B6+)
for klass in ns.classes {
totalTypes += 1; boundTypes += skipClass(into: &skips, into: &types, klass: klass, namespace: ns.name, context: context)
totalTypes += 1
boundTypes += skipClass(into: &skips, into: &types,
boundCallables: &boundCallables, totalCallables: &totalCallables,
klass: klass, namespace: ns.name, context: context)
}
for iface in ns.interfaces {
totalTypes += 1; boundTypes += skipInterface(into: &skips, into: &types, iface: iface, namespace: ns.name, context: context)
@ -162,14 +165,24 @@ private func checkBindable(_ info: SymbolInfo, fullName: String, cIdentifier: St
return nil
}
private func skipClass(into skips: inout [SkipEntry], into types: inout [TypePlan], klass: Class, namespace: String, context: MapContext) -> Int {
private func skipClass(
into skips: inout [SkipEntry], into types: inout [TypePlan],
boundCallables: inout Int, totalCallables: inout Int,
klass: Class, namespace: String, context: MapContext
) -> Int {
let fullName = "\(namespace).\(klass.name)"
// Every constructor/method/static function is a callable considered for
// coverage, whether or not it ends up planned.
totalCallables += klass.constructors.count + klass.methods.count + klass.functions.count
if !klass.symbolInfo.isBindable {
skips.append(SkipEntry(symbol: fullName, cIdentifier: klass.cType,
reason: .notIntrospectable, detail: "non-introspectable class"))
return 0
}
let plan = planClass(klass, context: context)
let (plan, memberSkips) = planClass(klass, context: context)
skips.append(contentsOf: memberSkips)
boundCallables += plan.constructors.count + plan.methods.count + plan.functions.count
types.append(.class(plan))
return 1
}
@ -249,6 +262,9 @@ private let knownMissingCFunctions: Set<String> = [
"g_access", "g_chdir", "g_chmod", "g_creat", "g_fopen", "g_freopen",
"g_fsync", "g_lstat", "g_mkdir", "g_open", "g_remove", "g_rename",
"g_rmdir", "g_stat", "g_unlink", "g_utime",
// Removed from modern GLib (present in the GIR, absent from the shared
// object) these fail only at link time, not compile time.
"g_thread_init", "g_thread_init_with_errorcheck_mutexes",
]
/// Swift type names that boxed records must not shadow.
@ -289,8 +305,13 @@ func planInterface(_ iface: Interface, context: MapContext) -> InterfacePlan {
)
}
/// Plans a class as a GObject wrapper with cross-module inheritance.
func planClass(_ klass: Class, context: MapContext) -> ClassPlan {
/// Plans a class as a GObject wrapper with cross-module inheritance, planning
/// its constructors, instance methods, and static functions. Members that
/// cannot be planned are returned as skip entries (the class itself is still
/// generated Rule 3 skips the *member*, not the whole type).
///
/// - Returns: The class plan and the skip entries for its unplannable members.
func planClass(_ klass: Class, context: MapContext) -> (plan: ClassPlan, memberSkips: [SkipEntry]) {
let girName = "\(context.currentNamespace).\(klass.name)"
let registry = context.registry
@ -317,7 +338,38 @@ func planClass(_ klass: Class, context: MapContext) -> ClassPlan {
return registry.swiftTypeName(for: resolved, in: context.currentModule)
}
return ClassPlan(
// Members
var memberSkips: [SkipEntry] = []
/// Records the outcome of planning one member.
func collect(_ result: CallablePlanResult, into plans: inout [CallablePlan]) {
switch result {
case .success(let plan): plans.append(plan)
case .skip(let entry): memberSkips.append(entry)
}
}
var constructorPlans: [CallablePlan] = []
// Abstract classes cannot be instantiated directly their constructors
// belong to concrete subclasses, so skip them here.
if !klass.isAbstract {
for ctor in klass.constructors where ctor.symbolInfo.isBindable {
collect(planConstructor(ctor, className: klass.name, descendsIU: descendsIU, context: context),
into: &constructorPlans)
}
}
var methodPlans: [CallablePlan] = []
for method in klass.methods where method.symbolInfo.isBindable {
collect(planMethod(method, context: context), into: &methodPlans)
}
var functionPlans: [CallablePlan] = []
for fn in klass.functions where fn.symbolInfo.isBindable {
collect(planFunction(fn, context: context), into: &functionPlans)
}
let plan = ClassPlan(
name: klass.name, cType: klass.cType,
parent: parentSwiftName,
isOpen: isOpen,
@ -325,11 +377,12 @@ func planClass(_ klass: Class, context: MapContext) -> ClassPlan {
getTypeFunction: klass.getTypeFunction,
descendsFromInitiallyUnowned: descendsIU,
interfaces: interfaceNames,
constructors: [],
methods: [],
functions: [],
constructors: constructorPlans,
methods: methodPlans,
functions: functionPlans,
doc: klass.doc
)
return (plan, memberSkips)
}
/// Plans an enumeration, deduplicating raw values.
@ -416,46 +469,61 @@ enum CallablePlanResult {
case skip(SkipEntry)
}
/// Plans a namespace-level function, checking every parameter and the return
/// type for mappability. The whole function is skipped if any part fails.
func planFunction(_ fn: GlobalFunction, context: MapContext) -> CallablePlanResult {
let fullName = "\(context.currentNamespace).\(fn.name)"
let swiftName = swiftFunctionName(fn.name)
/// Plans a function or method: checks every parameter and the return type for
/// mappability, skipping the whole callable if any part fails. The instance
/// parameter (for methods) is handled by `planParameters` as a `self`
/// passthrough.
///
/// - Parameters:
/// - fullName: Fully qualified GIR symbol name for skip reporting.
/// - swiftName: The Swift function/method name (keyword-escaped).
/// - cIdentifier: The C function symbol to call.
/// - parameters: The callable's parameters (may include an instance parameter).
/// - returnValue: The callable's return value.
/// - throwsGError: Whether the callable takes a trailing `GError**`.
/// - doc: Documentation from the GIR.
/// - isStatic: `true` for free/static functions, `false` for instance methods.
/// - context: The resolution context.
/// - Returns: A complete plan, or a skip entry with a machine-readable reason.
private func planCallable(
fullName: String, swiftName: String, cIdentifier: String,
parameters: [Parameter], returnValue: ReturnValue, throwsGError: Bool,
doc: String?, isStatic: Bool, context: MapContext
) -> CallablePlanResult {
// GError throws deferred to Phase C2
if fn.throwsGError {
return .skip(SkipEntry(symbol: fullName, cIdentifier: fn.cIdentifier,
if throwsGError {
return .skip(SkipEntry(symbol: fullName, cIdentifier: cIdentifier,
reason: .unknownType, detail: "GError throws planned for Phase C2"))
}
// Check parameters
let paramPlanResult = planParameters(fn.parameters, context: context)
let paramPlanResult = planParameters(parameters, context: context)
guard case .success(let paramPlans) = paramPlanResult else {
if case .skip(let entry) = paramPlanResult {
return .skip(SkipEntry(symbol: fullName, cIdentifier: fn.cIdentifier,
return .skip(SkipEntry(symbol: fullName, cIdentifier: cIdentifier,
reason: entry.reason, detail: entry.detail))
}
return .skip(SkipEntry(symbol: fullName, cIdentifier: fn.cIdentifier,
return .skip(SkipEntry(symbol: fullName, cIdentifier: cIdentifier,
reason: .unknownType, detail: "parameter planning failed"))
}
// Map return type
let returnMapping: Mapping?
if fn.returnValue.type != .void {
switch Result(catching: { try map(fn.returnValue.type, nullable: fn.returnValue.isNullable,
transfer: fn.returnValue.transferOwnership, context: context) }) {
if returnValue.type != .void {
switch Result(catching: { try map(returnValue.type, nullable: returnValue.isNullable,
transfer: returnValue.transferOwnership, context: context) }) {
case .success(let returnMap):
if !returnMap.isReadyForCallables {
return .skip(SkipEntry(symbol: fullName, cIdentifier: fn.cIdentifier,
return .skip(SkipEntry(symbol: fullName, cIdentifier: cIdentifier,
reason: .unknownType,
detail: "return type '\(returnMap.swiftType)' is not yet generated (category: \(returnMap.category))"))
}
returnMapping = returnMap
case .failure(let error as MapError):
return .skip(SkipEntry(symbol: fullName, cIdentifier: fn.cIdentifier,
return .skip(SkipEntry(symbol: fullName, cIdentifier: cIdentifier,
reason: error.reason, detail: error.detail))
case .failure:
return .skip(SkipEntry(symbol: fullName, cIdentifier: fn.cIdentifier,
return .skip(SkipEntry(symbol: fullName, cIdentifier: cIdentifier,
reason: .unknownType, detail: "return type mapping failed"))
}
} else {
@ -463,12 +531,63 @@ func planFunction(_ fn: GlobalFunction, context: MapContext) -> CallablePlanResu
}
return .success(CallablePlan(
name: swiftName, cIdentifier: fn.cIdentifier,
name: swiftName, cIdentifier: cIdentifier,
parameters: paramPlans, returnMapping: returnMapping,
isStatic: true, isConstructor: false, ownershipInit: nil, doc: fn.doc
isStatic: isStatic, isConstructor: false, ownershipInit: nil, doc: doc
))
}
/// Plans a namespace-level (or static) function.
func planFunction(_ fn: GlobalFunction, context: MapContext) -> CallablePlanResult {
planCallable(
fullName: "\(context.currentNamespace).\(fn.name)",
swiftName: swiftFunctionName(fn.name), cIdentifier: fn.cIdentifier,
parameters: fn.parameters, returnValue: fn.returnValue,
throwsGError: fn.throwsGError, doc: fn.doc, isStatic: true, context: context)
}
/// Plans an instance method. The instance parameter becomes `self.pointer`.
func planMethod(_ method: Method, context: MapContext) -> CallablePlanResult {
planCallable(
fullName: "\(context.currentNamespace).\(method.name)",
swiftName: swiftFunctionName(method.name), cIdentifier: method.cIdentifier,
parameters: method.parameters, returnValue: method.returnValue,
throwsGError: method.throwsGError, doc: method.doc, isStatic: false, context: context)
}
/// Plans a constructor as a Swift `convenience init`. The C constructor's
/// returned instance pointer is adopted through the class's designated
/// `init(takingOwnership:)`, which sinks a floating reference when the class
/// descends from `InitiallyUnowned`.
///
/// - Parameters:
/// - ctor: The GIR constructor.
/// - className: The owning class's name (for skip reporting).
/// - descendsIU: Whether the class descends from `InitiallyUnowned`.
/// - context: The resolution context.
/// - Returns: A constructor plan, or a skip entry.
func planConstructor(_ ctor: Constructor, className: String, descendsIU: Bool, context: MapContext) -> CallablePlanResult {
let fullName = "\(context.currentNamespace).\(className).\(ctor.name)"
if ctor.throwsGError {
return .skip(SkipEntry(symbol: fullName, cIdentifier: ctor.cIdentifier,
reason: .unknownType, detail: "GError throws planned for Phase C2"))
}
let paramPlanResult = planParameters(ctor.parameters, context: context)
guard case .success(let paramPlans) = paramPlanResult else {
if case .skip(let entry) = paramPlanResult {
return .skip(SkipEntry(symbol: fullName, cIdentifier: ctor.cIdentifier,
reason: entry.reason, detail: entry.detail))
}
return .skip(SkipEntry(symbol: fullName, cIdentifier: ctor.cIdentifier,
reason: .unknownType, detail: "constructor parameter planning failed"))
}
return .success(CallablePlan(
name: swiftFunctionName(ctor.name), cIdentifier: ctor.cIdentifier,
parameters: paramPlans, returnMapping: nil,
isStatic: false, isConstructor: true,
ownershipInit: descendsIU ? .sinkingRef : .takingOwnership, doc: ctor.doc))
}
/// Plans the parameters of a callable. Returns `.skip` if any parameter
/// has an unsupported direction, or if any type cannot be mapped.
func planParameters(
@ -477,6 +596,18 @@ func planParameters(
var plans: [ParameterPlan] = []
for (index, param) in parameters.enumerated() {
// The instance parameter is always `self` passed as `self.pointer`,
// never type-checked (its type is the enclosing class, which is a
// `.needsClass` mapping that would otherwise fail the ready check).
if param.isInstanceParameter {
plans.append(ParameterPlan(
swiftName: "self", cArgIndex: index,
mapping: Mapping(swiftType: "Self", cSwiftType: "UnsafeMutableRawPointer",
marshalIn: .direct, marshalOut: .direct),
isInstanceParameter: true))
continue
}
// Variadic parameter (GIR names it "..." with a <varargs/> child):
// C variadic calls cannot be bridged from Swift.
if param.name == "..." {
@ -555,29 +686,44 @@ enum ParameterPlanResult {
// MARK: - Naming helpers
/// Converts a C function name to a Swift function name (camelCase).
/// Example: `"gtk_widget_show"` `"show"`, `"g_list_length"` `"length"`.
func swiftFunctionName(_ cName: String) -> String {
// Strip common prefixes and convert snake_case to camelCase
var parts = cName.split(separator: "_").map(String.init)
// Drop leading prefixes like "g_", "gtk_", "gdk_" keep only the meaningful parts
// Strategy: drop the first segment if it looks like a namespace prefix (< 3 chars)
if parts.count > 1 && parts[0].count < 5 && parts[0].allSatisfy({ $0.isLowercase }) {
parts = Array(parts.dropFirst())
/// Converts a snake_case identifier to lowerCamelCase without dropping any
/// segment. The GIR `name` attribute is already namespace-stripped (e.g.
/// `"set_application_name"`, not `"g_set_application_name"`), so no prefix
/// removal is wanted doing so was the bug that turned setters like
/// `g_set_application_name` into `applicationName`, silently dropping the verb.
///
/// A leading digit (which cannot begin a Swift identifier even when
/// backtick-escaped) is guarded with an underscore.
///
/// - Parameter snake: A snake_case identifier from the GIR.
/// - Returns: The lowerCamelCase spelling.
func camelCased(_ snake: String) -> String {
let parts = snake.split(separator: "_", omittingEmptySubsequences: true).map(String.init)
guard let first = parts.first else { return snake }
var result = first.lowercased()
for word in parts.dropFirst() {
result += word.prefix(1).uppercased() + word.dropFirst()
}
// If the result starts with the first part being very generic (e.g. "object", "type"),
// we might need to keep more context. For now, just camelCase what remains.
guard !parts.isEmpty else { return cName }
let camel = parts.enumerated().map { idx, word in
idx == 0 ? word.lowercased() : word.capitalized
}.joined()
if result.first?.isNumber == true { result = "_\(result)" }
return result
}
/// Converts a GIR function/method name to a Swift name (lowerCamelCase),
/// escaping keywords. Example: `"set_application_name"` `"setApplicationName"`,
/// `"show"` `"show"`.
func swiftFunctionName(_ girName: String) -> String {
let camel = camelCased(girName)
guard !camel.isEmpty else { return girName }
return swiftKeywords.contains(camel) ? "`\(camel)`" : camel
}
/// Converts a GIR parameter name to a Swift parameter name, escaping keywords.
/// Converts a GIR parameter name to a Swift parameter name (lowerCamelCase),
/// escaping keywords. Example: `"application_name"` `"applicationName"`.
func swiftParameterName(_ girName: String) -> String {
if swiftKeywords.contains(girName) { return "`\(girName)`" }
return girName
let camel = camelCased(girName)
guard !camel.isEmpty else { return girName }
if swiftKeywords.contains(camel) { return "`\(camel)`" }
return camel
}
/// Converts a GIR member name to a Swift enum case name, escaping keywords.

View file

@ -58,7 +58,8 @@ struct SwiftGtkGenCLI {
}
// Write scaffolding (Package.swift, C module maps, umbrella headers)
let scaffolding = CodeGenerator.generateMonorepoScaffolding(analysis: analysis)
let scaffolding = CodeGenerator.generateMonorepoScaffolding(
analysis: analysis, includeSmokeTarget: args.includeSmokeTarget)
for (relativePath, content) in scaffolding.sorted(by: { $0.key < $1.key }) {
let fileURL = outputRoot.appendingPathComponent(relativePath)
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
@ -106,6 +107,7 @@ struct SwiftGtkGenCLI {
var monorepoConfigTOML: String = ""
var output: String = "."
var emitSkipReport: Bool = false
var includeSmokeTarget: Bool = false
}
/// Parses command-line arguments.
@ -124,6 +126,8 @@ struct SwiftGtkGenCLI {
cli.output = args.isEmpty ? "." : args.removeFirst()
case "--skip-report":
cli.emitSkipReport = true
case "--smoke-target":
cli.includeSmokeTarget = true
default:
break
}
@ -138,6 +142,7 @@ struct SwiftGtkGenCLI {
print(" --monorepo-config PATH Monorepo TOML config (required)")
print(" --output DIR Output directory (default: .)")
print(" --skip-report Write skip-reports/<Module>.json and coverage-summary.json")
print(" --smoke-target Add a SmokeTests test target to the generated Package.swift")
print(" --help, -h Show this help")
}
}

View file

@ -76,7 +76,8 @@ struct FunctionGenerationTests {
#expect(plan.parameters[0].mapping.marshalIn == .stringToC)
let body = render(plan)
#expect(body.contains("application_name.withCString { cString0 in"))
#expect(body.contains("func setApplicationName(applicationName: String)"))
#expect(body.contains("applicationName.withCString { cString0 in"))
#expect(body.contains("g_set_application_name(cString0)"))
}
@ -93,7 +94,7 @@ struct FunctionGenerationTests {
Issue.record("expected bitfield function to plan successfully"); return
}
let body = render(plan)
#expect(body.contains("GLogLevelFlags(rawValue: numericCast(fatal_mask.rawValue))"))
#expect(body.contains("GLogLevelFlags(rawValue: numericCast(fatalMask.rawValue))"))
#expect(body.contains("LogLevelFlags(rawValue: numericCast("))
}

View file

@ -2940,6 +2940,18 @@
"reason" : "callbackWithoutUserData",
"symbol" : "GLib.thread_foreach"
},
{
"cIdentifier" : "g_thread_init",
"detail" : "C symbol 'g_thread_init' is not exported by the system library",
"reason" : "unknownType",
"symbol" : "GLib.thread_init"
},
{
"cIdentifier" : "g_thread_init_with_errorcheck_mutexes",
"detail" : "C symbol 'g_thread_init_with_errorcheck_mutexes' is not exported by the system library",
"reason" : "unknownType",
"symbol" : "GLib.thread_init_with_errorcheck_mutexes"
},
{
"cIdentifier" : "g_thread_self",
"detail" : "return type 'Thread' is not yet generated (category: needsRecord)",
@ -3315,7 +3327,7 @@
],
"module" : "GLib",
"stats" : {
"boundCallables" : 293,
"boundCallables" : 291,
"boundTypes" : 246,
"totalCallables" : 724,
"totalTypes" : 367

View file

@ -126,6 +126,12 @@
"reason" : "callbackWithoutUserData",
"symbol" : "GObject.InterfaceInitFunc"
},
{
"cIdentifier" : "g_object_newv",
"detail" : "parameter 'parameters': C array bridging not yet implemented",
"reason" : "arrayWithoutLength",
"symbol" : "GObject.Object.newv"
},
{
"cIdentifier" : "GObjectClass",
"detail" : "GObject class struct for 'Object'",
@ -372,6 +378,36 @@
"reason" : "plainRecord",
"symbol" : "GObject.WeakRef"
},
{
"cIdentifier" : "g_type_module_add_interface",
"detail" : "parameter 'interface_info': 'GObject.InterfaceInfo' has no GType registration or lifetime functions",
"reason" : "plainRecord",
"symbol" : "GObject.add_interface"
},
{
"cIdentifier" : "g_binding_group_bind",
"detail" : "parameter 'target' type 'Object' is not yet generated",
"reason" : "unknownType",
"symbol" : "GObject.bind"
},
{
"cIdentifier" : "g_object_bind_property",
"detail" : "parameter 'target' type 'Object' is not yet generated",
"reason" : "unknownType",
"symbol" : "GObject.bind_property"
},
{
"cIdentifier" : "g_object_bind_property_with_closures",
"detail" : "parameter 'target' type 'Object' is not yet generated",
"reason" : "unknownType",
"symbol" : "GObject.bind_property_with_closures"
},
{
"cIdentifier" : "g_binding_group_bind_with_closures",
"detail" : "parameter 'target' type 'Object' is not yet generated",
"reason" : "unknownType",
"symbol" : "GObject.bind_with_closures"
},
{
"cIdentifier" : "g_boxed_type_register_static",
"detail" : "parameter 'boxed_copy': callback 'GObject.BoxedCopyFunc' not yet supported as a mapped type",
@ -552,6 +588,48 @@
"reason" : "unknownType",
"symbol" : "GObject.clear_signal_handler"
},
{
"cIdentifier" : "g_signal_group_connect_closure",
"detail" : "parameter 'closure' type 'Closure' is not yet generated",
"reason" : "unknownType",
"symbol" : "GObject.connect_closure"
},
{
"cIdentifier" : "g_signal_group_connect_data",
"detail" : "parameter 'c_handler': callback 'GObject.Callback' not yet supported as a mapped type",
"reason" : "callbackWithoutUserData",
"symbol" : "GObject.connect_data"
},
{
"cIdentifier" : "g_signal_group_connect_swapped",
"detail" : "parameter 'c_handler': callback 'GObject.Callback' not yet supported as a mapped type",
"reason" : "callbackWithoutUserData",
"symbol" : "GObject.connect_swapped"
},
{
"cIdentifier" : "g_binding_dup_source",
"detail" : "return type 'Object?' is not yet generated (category: needsClass)",
"reason" : "unknownType",
"symbol" : "GObject.dup_source"
},
{
"cIdentifier" : "g_binding_group_dup_source",
"detail" : "return type 'Object?' is not yet generated (category: needsClass)",
"reason" : "unknownType",
"symbol" : "GObject.dup_source"
},
{
"cIdentifier" : "g_binding_dup_target",
"detail" : "return type 'Object?' is not yet generated (category: needsClass)",
"reason" : "unknownType",
"symbol" : "GObject.dup_target"
},
{
"cIdentifier" : "g_signal_group_dup_target",
"detail" : "return type 'Object?' is not yet generated (category: needsClass)",
"reason" : "unknownType",
"symbol" : "GObject.dup_target"
},
{
"cIdentifier" : "g_enum_complete_type_info",
"detail" : "'info' has direction=out",
@ -612,6 +690,66 @@
"reason" : "arrayWithoutLength",
"symbol" : "GObject.flags_register_static"
},
{
"cIdentifier" : "g_param_spec_get_default_value",
"detail" : "return type 'Value' is not yet generated (category: needsRecord)",
"reason" : "unknownType",
"symbol" : "GObject.get_default_value"
},
{
"cIdentifier" : "g_object_get_property",
"detail" : "parameter 'value' type 'Value' is not yet generated",
"reason" : "unknownType",
"symbol" : "GObject.get_property"
},
{
"cIdentifier" : "g_param_spec_get_redirect_target",
"detail" : "return type 'ParamSpec?' is not yet generated (category: needsClass)",
"reason" : "unknownType",
"symbol" : "GObject.get_redirect_target"
},
{
"cIdentifier" : "g_binding_get_source",
"detail" : "return type 'Object?' is not yet generated (category: needsClass)",
"reason" : "unknownType",
"symbol" : "GObject.get_source"
},
{
"cIdentifier" : "g_binding_get_target",
"detail" : "return type 'Object?' is not yet generated (category: needsClass)",
"reason" : "unknownType",
"symbol" : "GObject.get_target"
},
{
"cIdentifier" : "g_object_getv",
"detail" : "parameter 'names': C array bridging not yet implemented",
"reason" : "arrayWithoutLength",
"symbol" : "GObject.getv"
},
{
"cIdentifier" : "g_object_interface_find_property",
"detail" : "parameter 'g_iface': 'GObject.TypeInterface' has no GType registration or lifetime functions",
"reason" : "plainRecord",
"symbol" : "GObject.interface_find_property"
},
{
"cIdentifier" : "g_object_interface_install_property",
"detail" : "parameter 'g_iface': 'GObject.TypeInterface' has no GType registration or lifetime functions",
"reason" : "plainRecord",
"symbol" : "GObject.interface_install_property"
},
{
"cIdentifier" : "g_object_interface_list_properties",
"detail" : "parameter 'g_iface': 'GObject.TypeInterface' has no GType registration or lifetime functions",
"reason" : "plainRecord",
"symbol" : "GObject.interface_list_properties"
},
{
"cIdentifier" : "g_object_notify_by_pspec",
"detail" : "parameter 'pspec' type 'ParamSpec' is not yet generated",
"reason" : "unknownType",
"symbol" : "GObject.notify_by_pspec"
},
{
"cIdentifier" : "g_param_spec_boolean",
"detail" : "parameter 'nick' is a nullable string",
@ -792,6 +930,54 @@
"reason" : "unknownType",
"symbol" : "GObject.param_values_cmp"
},
{
"cIdentifier" : "g_object_ref",
"detail" : "return type 'Object' is not yet generated (category: needsClass)",
"reason" : "unknownType",
"symbol" : "GObject.ref"
},
{
"cIdentifier" : "g_object_ref_sink",
"detail" : "return type 'Object' is not yet generated (category: needsClass)",
"reason" : "unknownType",
"symbol" : "GObject.ref_sink"
},
{
"cIdentifier" : "g_type_module_register_enum",
"detail" : "parameter 'const_static_values': C array has no length annotation",
"reason" : "arrayWithoutLength",
"symbol" : "GObject.register_enum"
},
{
"cIdentifier" : "g_type_module_register_flags",
"detail" : "parameter 'const_static_values': C array has no length annotation",
"reason" : "arrayWithoutLength",
"symbol" : "GObject.register_flags"
},
{
"cIdentifier" : "g_type_module_register_type",
"detail" : "parameter 'type_info': 'GObject.TypeInfo' has no GType registration or lifetime functions",
"reason" : "plainRecord",
"symbol" : "GObject.register_type"
},
{
"cIdentifier" : "g_object_set_property",
"detail" : "parameter 'value' type 'Value' is not yet generated",
"reason" : "unknownType",
"symbol" : "GObject.set_property"
},
{
"cIdentifier" : "g_binding_group_set_source",
"detail" : "parameter 'source' type 'Object?' is not yet generated",
"reason" : "unknownType",
"symbol" : "GObject.set_source"
},
{
"cIdentifier" : "g_signal_group_set_target",
"detail" : "parameter 'target' type 'Object?' is not yet generated",
"reason" : "unknownType",
"symbol" : "GObject.set_target"
},
{
"cIdentifier" : "g_signal_accumulator_first_wins",
"detail" : "parameter 'ihint': 'GObject.SignalInvocationHint' has no GType registration or lifetime functions",
@ -1277,13 +1463,19 @@
"detail" : "C symbol 'g_variant_get_gtype' is not exported by the system library",
"reason" : "unknownType",
"symbol" : "GObject.variant_get_gtype"
},
{
"cIdentifier" : "g_object_watch_closure",
"detail" : "parameter 'closure' type 'Closure' is not yet generated",
"reason" : "unknownType",
"symbol" : "GObject.watch_closure"
}
],
"module" : "GObject",
"stats" : {
"boundCallables" : 34,
"boundCallables" : 67,
"boundTypes" : 60,
"totalCallables" : 185,
"totalCallables" : 284,
"totalTypes" : 122
}
}

38
scripts/smoke-test.sh Executable file
View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
# smoke-test.sh — runtime smoke tests for the generated bindings.
#
# The compile gate proves the generated Swift type-checks. This goes further:
# it generates the tier-1 bindings, links them against the REAL C libraries via
# pkg-config, and runs hand-written swift-testing cases (smoke/*.swift) that
# invoke real GLib/GObject functionality through the wrappers and check the
# results at runtime.
#
# Usage: scripts/smoke-test.sh [--fresh]
#
# Output goes to ${TMPDIR:-/tmp}/swift-gtk-gen-smoke/tier-1 (a stable path so
# incremental swift builds stay fast between runs).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT="${TMPDIR:-/tmp}/swift-gtk-gen-smoke/tier-1"
CONFIG="$ROOT/configs/tier1.toml"
[[ "${1:-}" == "--fresh" ]] && rm -rf "$OUT"
mkdir -p "$OUT"
echo "==> Building generator"
swift build --package-path "$ROOT"
BIN="$(swift build --package-path "$ROOT" --show-bin-path)/swift-gtk-gen"
echo "==> Generating bindings (with SmokeTests target) into $OUT"
"$BIN" --monorepo-config "$CONFIG" --output "$OUT" --smoke-target >"$OUT/generate.log" 2>&1
echo "==> Installing smoke tests"
mkdir -p "$OUT/Tests/SmokeTests"
# Refresh so edits to smoke/*.swift always take effect.
rm -f "$OUT/Tests/SmokeTests/"*.swift
cp "$ROOT/smoke/"*.swift "$OUT/Tests/SmokeTests/"
echo "==> Running smoke tests against the real C libraries"
swift test --package-path "$OUT"

54
smoke/SmokeTests.swift Normal file
View file

@ -0,0 +1,54 @@
// SmokeTests.swift
// Runtime smoke tests for the GENERATED bindings.
//
// These are fundamentally different from the compile gate. The gate proves the
// generated Swift *type-checks*; these prove the underlying C functionality is
// actually reachable and correct *through the wrappers* real GLib/GObject
// symbols, linked via pkg-config, invoked at runtime and their results checked.
//
// This file is not generated. `scripts/smoke-test.sh` generates the tier-1
// bindings with a SmokeTests target and copies this file in before running
// `swift test`.
import Testing
import GLib
import GObject
@Suite("Runtime smoke tests")
struct SmokeTests {
// MARK: - Free functions (string marshalling: withCString + String(cString:))
@Test("g_ascii_strup uppercases a string through the binding")
func asciiUppercase() {
#expect(asciiStrup(str: "hello, world", len: -1) == "HELLO, WORLD")
}
@Test("g_ascii_strdown lowercases a string through the binding")
func asciiLowercase() {
#expect(asciiStrdown(str: "HELLO, World", len: -1) == "hello, world")
}
// MARK: - Object construction + instance methods
@Test("A GObject subclass constructs and answers C queries")
func constructAndQuery() {
let group = BindingGroup()
// GBindingGroup is a plain GObject (not InitiallyUnowned), so a freshly
// constructed instance is not floating. This exercises, end to end:
// the C constructor, ownership adoption, the instance-pointer cast, and
// the gboolean Bool return bridge.
#expect(group.isFloating() == false)
// Paired notify freeze/thaw must not trap.
group.freezeNotify()
group.thawNotify()
}
@Test("GObject user-data round-trips through set/get")
func userDataRoundtrip() {
let group = BindingGroup()
let marker = UnsafeMutableRawPointer(bitPattern: 0xBEEF)
group.setData(key: "smoke", data: marker)
#expect(group.getData(key: "smoke") == marker)
}
}