diff --git a/Sources/SwiftGtkGenCore/BindingPlan.swift b/Sources/SwiftGtkGenCore/BindingPlan.swift index 39ce7fc..6eebeae 100644 --- a/Sources/SwiftGtkGenCore/BindingPlan.swift +++ b/Sources/SwiftGtkGenCore/BindingPlan.swift @@ -76,7 +76,11 @@ public enum MarshalOut: Equatable, Sendable { case gbooleanToBool /// Copy a C string, optionally freeing the source. /// - 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. /// - Parameter sink: When `true`, call `g_object_ref_sink` (for InitiallyUnowned constructors). case objectWrap(sink: Bool) diff --git a/Sources/SwiftGtkGenCore/PlanRenderer.swift b/Sources/SwiftGtkGenCore/PlanRenderer.swift index e16d5f7..268959f 100644 --- a/Sources/SwiftGtkGenCore/PlanRenderer.swift +++ b/Sources/SwiftGtkGenCore/PlanRenderer.swift @@ -96,10 +96,14 @@ public func renderModule(_ plan: ModulePlan) -> [String: String] { return files } /// Tests whether a generated filename follows the PascalCase convention — starts -/// with an uppercase ASCII letter, contains only ASCII letters/digits, and has -/// no run of more than 3 consecutive uppercase letters (catches unconverted C -/// spellings like `cclosureMarshalBOOLEANFLAGS.swift` while allowing legitimate -/// 2–3-letter acronym runs like `IOChannel.swift`, `FileIOStream.swift`). +/// with an uppercase ASCII letter, contains only ASCII letters/digits, and +/// never has a run of more than 3 consecutive uppercase letters immediately +/// preceded by a lowercase letter. That last rule rejects unconverted C +/// 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`. /// - 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 base.allSatisfy({ ($0.isLetter && $0.isASCII) || $0.isNumber }) else { return false } var run = 0 + var precededByLower = false for ch in base { - run = ch.isUppercase ? run + 1 : 0 - if run > 3 { return false } + if ch.isUppercase && ch.isASCII { + run += 1 + if run > 3 && precededByLower { return false } + } else { + run = 0 + precededByLower = ch.isLowercase && ch.isASCII + } } return true } @@ -461,8 +471,14 @@ private func renderRecord(_ plan: RecordPlan) -> String { lines.append("public final class \(plan.name) {") lines.append(" public let pointer: UnsafeMutableRawPointer") lines.append("") - lines.append(" /// Adopts an owned boxed pointer; the wrapper takes responsibility") - lines.append(" /// for freeing it. Use for `transfer-ownership=\"full\"` returns.") + if plan.freeFunction != nil { + 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(" self.pointer = pointer") lines.append(" }") @@ -768,7 +784,20 @@ private func renderWrapperExpr(for p: ParameterPlan, rawName: String, ownerIsInt return "\(p.mapping.swiftType)(retaining: \(rawName))" } 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 baseType = isOptional ? String(p.mapping.swiftType.dropLast()) : p.mapping.swiftType return "\(baseType)(retaining: \(rawName))" @@ -926,13 +955,31 @@ private func callbackBoxRelease(_ plan: CallablePlan, indent: String) -> [String private func outParamLocalType(_ param: ParameterPlan) -> String { switch param.mapping.marshalIn { case .stringToC: + if case .stringCopy(_, let constPointee) = param.mapping.marshalOut, constPointee { + // c:type is `const char**`: the Clang importer expects + // UnsafePointer? as the pointee, not + // UnsafeMutablePointer? (that's only correct when the + // callee hands over a mutable/owned `char**`). + return "UnsafePointer?" + } return "UnsafeMutablePointer?" case .boolToGboolean: return "Int32" 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` 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: - return param.mapping.cSwiftType + let t = param.mapping.cSwiftType + return t.hasSuffix("?") ? String(t.dropLast()) : t default: return "UnsafeMutablePointer?" } @@ -967,11 +1014,12 @@ private func outParamValueExpr(_ param: ParameterPlan, varName: String) -> Strin return "numericCast(\(varName))" case .gbooleanToBool: return "\(varName) != 0" - case .stringCopy: + case .stringCopy(let free, _): + let note = free ? " /* TODO: transfer-full out-string not freed */" : "" 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): return "\(swiftType)(rawValue: numericCast(\(varName).rawValue))!" case .bitfieldFromRaw(let swiftType): @@ -1413,7 +1461,7 @@ private func marshalReturn(_ cCall: String, mapping: Mapping) -> String { return "numericCast(\(cCall))" case .gbooleanToBool: return "\(cCall) != 0" - case .stringCopy(let free): + case .stringCopy(let free, _): if mapping.swiftType.hasSuffix("?") { let freeStr = free ? "/* TODO: g_free */" : "" return "\(cCall).map { String(cString: $0) \(freeStr) }" diff --git a/Sources/SwiftGtkGenCore/Planner.swift b/Sources/SwiftGtkGenCore/Planner.swift index 6d3483d..be9d9ab 100644 --- a/Sources/SwiftGtkGenCore/Planner.swift +++ b/Sources/SwiftGtkGenCore/Planner.swift @@ -152,12 +152,7 @@ private func planNamespace(_ ns: Namespace, context: MapContext) -> ModulePlan { // (enums, bitfields, records, classes, interfaces, aliases), never to // constants/functions/callbacks. func duplicateDependencyModule(_ girSimpleName: String) -> String? { - for dep in context.dependencyModules { - if context.registry.resolve(name: girSimpleName, namespace: dep) != nil { - return dep - } - } - return nil + context.registry.droppedShadow("\(ns.name).\(girSimpleName)") } func skipIfDuplicate(_ girSimpleName: String) -> Bool { guard let dep = duplicateDependencyModule(girSimpleName) else { return false } @@ -569,6 +564,11 @@ private let knownMissingCFunctions: Set = [ // Declared in the GdkPixbuf GIR but not exported through the public // umbrella header. "gdk_pixbuf_non_anim_new", + // Declared in , which the public + // umbrella () deliberately does not include — Broadway is an + // optional backend; unlike the GPU renderers (, + // , both included), its header is excluded. + "gsk_broadway_renderer_new", ] let knownMisleadingCFunctions: Set = [ @@ -1406,7 +1406,8 @@ func planParameters( // Out-params are collected and returned as Swift values rather than // passed as arguments. Map the VALUE type (not the pointer-to-pointer). 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 { case .success(let paramMapping): if !paramMapping.isReadyForCallables { diff --git a/Sources/SwiftGtkGenCore/TypeMapper.swift b/Sources/SwiftGtkGenCore/TypeMapper.swift index bed3ed5..0ea5a9d 100644 --- a/Sources/SwiftGtkGenCore/TypeMapper.swift +++ b/Sources/SwiftGtkGenCore/TypeMapper.swift @@ -138,9 +138,10 @@ public func map( _ type: GIRType, nullable: Bool, transfer: TransferOwnership, - context: MapContext + context: MapContext, + cType: String = "" ) 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) } @@ -151,7 +152,8 @@ public func map( private func _mapValue( type: GIRType, transfer: TransferOwnership, - context: MapContext + context: MapContext, + cType: String = "" ) throws(MapError) -> Mapping { switch type { @@ -169,9 +171,17 @@ private func _mapValue( case .float, .double: return try mapPrimitive(type) 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: - return .filenameMapping(free: transfer == .full) + let constOut = cType.isEmpty || cType.hasPrefix("const") + return .filenameMapping(free: transfer == .full, constOut: constOut) case .pointer: return .pointerMapping case .vaList: @@ -234,6 +244,20 @@ private func mapTypeRef( 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 { case .foreign(let foreignNS): throw MapError(reason: .foreignNamespace, @@ -457,18 +481,18 @@ extension Mapping { gvalue: GValueOps(typeMacro: "G_TYPE_POINTER", getterSuffix: "pointer", setterSuffix: "pointer")) - static func stringMapping(free: Bool) -> Mapping { + static func stringMapping(free: Bool, constOut: Bool = true) -> Mapping { Mapping( swiftType: "String", cSwiftType: "UnsafePointer?", - marshalIn: .stringToC, marshalOut: .stringCopy(free: free), + marshalIn: .stringToC, marshalOut: .stringCopy(free: free, constPointee: constOut), gvalue: GValueOps(typeMacro: "G_TYPE_STRING", getterSuffix: "string", setterSuffix: "string")) } - static func filenameMapping(free: Bool) -> Mapping { + static func filenameMapping(free: Bool, constOut: Bool = true) -> Mapping { Mapping( swiftType: "String", cSwiftType: "UnsafePointer?", - marshalIn: .stringToC, marshalOut: .stringCopy(free: free)) + marshalIn: .stringToC, marshalOut: .stringCopy(free: free, constPointee: constOut)) } } diff --git a/Sources/SwiftGtkGenCore/TypeRegistry.swift b/Sources/SwiftGtkGenCore/TypeRegistry.swift index 6977ba3..04cbebb 100644 --- a/Sources/SwiftGtkGenCore/TypeRegistry.swift +++ b/Sources/SwiftGtkGenCore/TypeRegistry.swift @@ -138,6 +138,18 @@ public struct TypeRegistry: Sendable { private let foreignNamespaces: Set /// Maps a GIR namespace name to its Swift module name. 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 = [] + /// 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. public static let objectGIRName = "GObject.Object" @@ -150,12 +162,18 @@ public struct TypeRegistry: Sendable { /// Namespaces referenced via `` but absent from `repositories` /// are recorded as foreign, so references into them resolve to /// ``TypeCategory/foreign(namespace:)`` rather than failing. - /// /// - Parameters: /// - 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 /// and treated as foreign even when a GIR is present. Defaults to empty. - public init(repositories: [String: Repository], manualNamespaces: Set = []) { + public init( + repositories: [String: Repository], + directDependencies: [String: Set] = [:], + manualNamespaces: Set = [] + ) { var namespaceToModule: [String: String] = [:] for (module, repo) in repositories { for ns in repo.namespaces { @@ -178,6 +196,51 @@ public struct TypeRegistry: Sendable { 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] = [:] + 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. @@ -395,15 +458,28 @@ public struct TypeRegistry: Sendable { /// The Swift spelling of a resolved type as written from `module`. /// - /// Types from another module are qualified (`GObject.Object`); types from - /// the current module are bare (`Widget`). + /// Bare (`Widget`) in the overwhelmingly common case. Every module + /// `@_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: /// - type: The resolved type to spell. /// - module: The Swift module the reference is being written in. /// - Returns: The Swift type name to emit. 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. diff --git a/Sources/SwiftGtkGenCore/XMLParser.swift b/Sources/SwiftGtkGenCore/XMLParser.swift index 1b2121e..7c553c4 100644 --- a/Sources/SwiftGtkGenCore/XMLParser.swift +++ b/Sources/SwiftGtkGenCore/XMLParser.swift @@ -222,7 +222,14 @@ final class GIRXMLDelegate: NSObject, XMLParserDelegate { stack.append(.ignored(elementName)) case "record": - guard let name = requireAttribute("name", from: attributeDict, for: elementName, parser: parser) else { return } + guard let name = attributeDict["name"] else { + // Anonymous nested (a C struct inside a , 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( name: name, cType: attributeDict["c:type"] ?? "", diff --git a/Sources/swift-gtk-gen/Main.swift b/Sources/swift-gtk-gen/Main.swift index 315c78c..2efed5f 100644 --- a/Sources/swift-gtk-gen/Main.swift +++ b/Sources/swift-gtk-gen/Main.swift @@ -38,7 +38,7 @@ struct SwiftGtkGenCLI { let outputRoot = URL(fileURLWithPath: monorepoConfig.outputDir) // 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) var outputs: [String: [String: String]] = [:] diff --git a/Tests/SwiftGtkGenCoreTests/NamingTests.swift b/Tests/SwiftGtkGenCoreTests/NamingTests.swift index 7a7275a..2ee29e7 100644 --- a/Tests/SwiftGtkGenCoreTests/NamingTests.swift +++ b/Tests/SwiftGtkGenCoreTests/NamingTests.swift @@ -47,9 +47,12 @@ struct NamingTests { #expect(!isValidGeneratedFileName("boxedFree.swift")) // lowerCamelCase #expect(!isValidGeneratedFileName("PARAM_MASK.swift")) // SCREAMING_SNAKE #expect(!isValidGeneratedFileName("CSET_a_2_z.swift")) // underscores - #expect(!isValidGeneratedFileName("cclosureMarshalBOOLEANFLAGS.swift")) // uppercase run - #expect(!isValidGeneratedFileName("MarshalBOOLEAN.swift")) // run > 3 caps + #expect(!isValidGeneratedFileName("cclosureMarshalBOOLEANFLAGS.swift")) // uppercase run, lowercase start + #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("MarshalBOOLEAN.swift")) // uppercase run, PascalCase start } // MARK: - File merging diff --git a/Tests/SwiftGtkGenCoreTests/RecordGenerationTests.swift b/Tests/SwiftGtkGenCoreTests/RecordGenerationTests.swift index c7da908..6624bcd 100644 --- a/Tests/SwiftGtkGenCoreTests/RecordGenerationTests.swift +++ b/Tests/SwiftGtkGenCoreTests/RecordGenerationTests.swift @@ -81,6 +81,9 @@ struct RecordGenerationTests { #expect(source.contains("g_variant_type_copy(_instancePointer(pointer))")) #expect(source.contains("isolated deinit {")) #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") @@ -91,6 +94,9 @@ struct RecordGenerationTests { #expect(!source.contains("retaining")) // The takingOwnership init is always present. #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 diff --git a/Tests/SwiftGtkGenCoreTests/RendererCallableTests.swift b/Tests/SwiftGtkGenCoreTests/RendererCallableTests.swift index 962981a..d120dfd 100644 --- a/Tests/SwiftGtkGenCoreTests/RendererCallableTests.swift +++ b/Tests/SwiftGtkGenCoreTests/RendererCallableTests.swift @@ -96,6 +96,36 @@ struct RendererCallableTests { #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?")) + + 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?")) + #expect(!renderCallable(constPlan).contains("UnsafeMutablePointer?")) + } + @Test("Swift return with out-params produces a labeled tuple") func returnWithOutParamsLabeledTuple() throws { let fn = GlobalFunction( diff --git a/Tests/SwiftGtkGenCoreTests/SignalGenerationTests.swift b/Tests/SwiftGtkGenCoreTests/SignalGenerationTests.swift index 6207cfa..9f3b2a3 100644 --- a/Tests/SwiftGtkGenCoreTests/SignalGenerationTests.swift +++ b/Tests/SwiftGtkGenCoreTests/SignalGenerationTests.swift @@ -30,6 +30,34 @@ struct SignalGenerationTests { 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 /// class file body plus the plan and skips. 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(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:")) + } } diff --git a/Tests/SwiftGtkGenCoreTests/TypeMapperTests.swift b/Tests/SwiftGtkGenCoreTests/TypeMapperTests.swift index f1b1d78..b91734b 100644 --- a/Tests/SwiftGtkGenCoreTests/TypeMapperTests.swift +++ b/Tests/SwiftGtkGenCoreTests/TypeMapperTests.swift @@ -255,6 +255,27 @@ struct TypeMapperTests { #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 @Test("Nullable string becomes Optional") diff --git a/Tests/SwiftGtkGenCoreTests/TypeRegistryTests.swift b/Tests/SwiftGtkGenCoreTests/TypeRegistryTests.swift index 9bc860f..4546ffd 100644 --- a/Tests/SwiftGtkGenCoreTests/TypeRegistryTests.swift +++ b/Tests/SwiftGtkGenCoreTests/TypeRegistryTests.swift @@ -172,6 +172,67 @@ struct TypeRegistryTests { #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") func manualNamespacesAreForeign() { let repo = Repository(namespaces: [ diff --git a/smoke/tier4/GdkSmoke.swift b/smoke/tier4/GdkSmoke.swift new file mode 100644 index 0000000..5e39f69 --- /dev/null +++ b/smoke/tier4/GdkSmoke.swift @@ -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 ` copies every `smoke/*.swift` +// plus `smoke/tier/*.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") + } +} diff --git a/smoke/tier4/GskSmoke.swift b/smoke/tier4/GskSmoke.swift new file mode 100644 index 0000000..540dd48 --- /dev/null +++ b/smoke/tier4/GskSmoke.swift @@ -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 ` copies every `smoke/*.swift` +// plus `smoke/tier/*.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) + } +}