From 144da7860e802254698bcd943c26b4096f877b8c Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Fri, 17 Jul 2026 15:43:30 -0400 Subject: [PATCH] Generate class methods and constructors with runtime smoke tests --- .../SwiftGtkGenCore/CodeGen+Scaffolding.swift | 63 ++--- Sources/SwiftGtkGenCore/PlanRenderer.swift | 173 ++++++++++--- Sources/SwiftGtkGenCore/Planner.swift | 238 ++++++++++++++---- Sources/swift-gtk-gen/Main.swift | 7 +- .../FunctionGenerationTests.swift | 5 +- docs/skip-baseline/tier1/GLib.json | 14 +- docs/skip-baseline/tier1/GObject.json | 196 ++++++++++++++- scripts/smoke-test.sh | 38 +++ smoke/SmokeTests.swift | 54 ++++ 9 files changed, 673 insertions(+), 115 deletions(-) create mode 100755 scripts/smoke-test.sh create mode 100644 smoke/SmokeTests.swift diff --git a/Sources/SwiftGtkGenCore/CodeGen+Scaffolding.swift b/Sources/SwiftGtkGenCore/CodeGen+Scaffolding.swift index 17e4f94..b57e785 100644 --- a/Sources/SwiftGtkGenCore/CodeGen+Scaffolding.swift +++ b/Sources/SwiftGtkGenCore/CodeGen+Scaffolding.swift @@ -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[.. 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[.. [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` +/// (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`, selected when the C function's parameter is a + /// complete struct pointer. + @inline(__always) + func _instancePointer(_ pointer: UnsafeMutableRawPointer) -> UnsafeMutablePointer { + 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 { diff --git a/Sources/SwiftGtkGenCore/Planner.swift b/Sources/SwiftGtkGenCore/Planner.swift index 652c30b..99575ab 100644 --- a/Sources/SwiftGtkGenCore/Planner.swift +++ b/Sources/SwiftGtkGenCore/Planner.swift @@ -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 = [ "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 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. diff --git a/Sources/swift-gtk-gen/Main.swift b/Sources/swift-gtk-gen/Main.swift index adc17b6..315c78c 100644 --- a/Sources/swift-gtk-gen/Main.swift +++ b/Sources/swift-gtk-gen/Main.swift @@ -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/.json and coverage-summary.json") + print(" --smoke-target Add a SmokeTests test target to the generated Package.swift") print(" --help, -h Show this help") } } diff --git a/Tests/SwiftGtkGenCoreTests/FunctionGenerationTests.swift b/Tests/SwiftGtkGenCoreTests/FunctionGenerationTests.swift index 7c98717..e03ef30 100644 --- a/Tests/SwiftGtkGenCoreTests/FunctionGenerationTests.swift +++ b/Tests/SwiftGtkGenCoreTests/FunctionGenerationTests.swift @@ -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(")) } diff --git a/docs/skip-baseline/tier1/GLib.json b/docs/skip-baseline/tier1/GLib.json index e80fb92..69d9a1f 100644 --- a/docs/skip-baseline/tier1/GLib.json +++ b/docs/skip-baseline/tier1/GLib.json @@ -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 diff --git a/docs/skip-baseline/tier1/GObject.json b/docs/skip-baseline/tier1/GObject.json index bef664c..aa21e69 100644 --- a/docs/skip-baseline/tier1/GObject.json +++ b/docs/skip-baseline/tier1/GObject.json @@ -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 } } diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 0000000..f436113 --- /dev/null +++ b/scripts/smoke-test.sh @@ -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" diff --git a/smoke/SmokeTests.swift b/smoke/SmokeTests.swift new file mode 100644 index 0000000..a9d2281 --- /dev/null +++ b/smoke/SmokeTests.swift @@ -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) + } +}