1
0
Fork 0

Force open class for classes with same-namespace subclasses

This commit is contained in:
Brendan Szymanski 2026-07-01 18:33:05 -04:00
parent 3a033057fb
commit 9dbd2b5a7a
3 changed files with 81 additions and 8 deletions

View file

@ -43,6 +43,10 @@ public struct AnalysisResult {
/// cross-namespace ancestors (those are not real Swift superclasses).
public var inheritedMethodNames: [String: Set<String>]
/// Set of fully-qualified type names (e.g. `"Gtk.Widget"`) that have
/// at least one subclass in the same namespace.
public var typesWithSubclasses: Set<String> = []
/// A function pattern rename rule extracted from the config.
public struct FunctionPatternEntry {
/// The fully-qualified type name this pattern applies to.
@ -79,6 +83,8 @@ public struct AnalysisResult {
/// - functionPatterns: Glob-style function rename rules.
/// - inheritedMethodNames: Map from each class to the set of method
/// names it inherits transitively from its parent class chain.
/// - typesWithSubclasses: Set of fully-qualified type names that have
/// at least one same-namespace subclass.
public init(classHierarchy: [String: String] = [:], generatedTypes: Set<String> = [],
manualTypes: Set<String> = [], ignoredTypes: Set<String> = [],
classOverrides: [String: ObjectOverrides] = [:],
@ -86,7 +92,8 @@ public struct AnalysisResult {
signalOverrides: [String: SignalOverrides] = [:],
propertyOverrides: [String: PropertyOverrides] = [:],
functionPatterns: [FunctionPatternEntry] = [],
inheritedMethodNames: [String: Set<String>] = [:]) {
inheritedMethodNames: [String: Set<String>] = [:],
typesWithSubclasses: Set<String> = []) {
self.classHierarchy = classHierarchy
self.generatedTypes = generatedTypes
self.manualTypes = manualTypes
@ -97,6 +104,7 @@ public struct AnalysisResult {
self.propertyOverrides = propertyOverrides
self.functionPatterns = functionPatterns
self.inheritedMethodNames = inheritedMethodNames
self.typesWithSubclasses = typesWithSubclasses
}
}
@ -156,6 +164,21 @@ public struct Analyzer {
}
}
// Invert the hierarchy to find parents that have at least one
// same-namespace subclass. Only same-namespace children are real
// Swift subclasses for the purpose of the "final" rule.
var typesWithSubclasses: Set<String> = []
for ns in repository.namespaces {
for cls in ns.classes {
let fullName = "\(ns.name).\(cls.name)"
guard let parentFullName = classHierarchy[fullName] else { continue }
let parentNs = parentFullName.split(separator: ".").first.map(String.init) ?? ""
if parentNs == ns.name {
typesWithSubclasses.insert(parentFullName)
}
}
}
// Build the inherited-method-name set for each class by walking the
// class hierarchy transitively. A subclass inherits the union of all
// method names declared by its parent, grandparent, etc.
@ -225,7 +248,8 @@ public struct Analyzer {
signalOverrides: signalOverrides,
propertyOverrides: propertyOverrides,
functionPatterns: functionPatterns,
inheritedMethodNames: inheritedMethodNames
inheritedMethodNames: inheritedMethodNames,
typesWithSubclasses: typesWithSubclasses
)
}
}

View file

@ -221,7 +221,9 @@ public struct CodeGenerator {
swift += Self.formatDocComment(cls.doc)
if concurrency == .mainActor { swift += "@MainActor\n" }
if let cfg = override?.cfgCondition { swift += "#if \(cfg)\n" }
let isFinal = override?.finalType ?? true
let userWantsFinal = override?.finalType ?? true
let hasSubclasses = analysis.typesWithSubclasses.contains("\(namespace).\(cls.name)")
let isFinal = userWantsFinal && !hasSubclasses
let classKeyword = isFinal ? "final class" : "open class"
let parentClause = cls.parent.map { ": \($0)" } ?? ""
swift += "public \(classKeyword) \(cls.name)\(parentClause) {\n"

View file

@ -605,8 +605,55 @@ func testRecordInGenerateFiles() throws {
#expect(!output.contains(", canFocus)"))
}
@Test("Optional boolean return is mapped with != 0")
func testOptionalBooleanReturnMapping() {
let result = CodeGenerator.wrapCReturnValue(callExpression: "g_value_get_boolean(&value)", returnType: .optional(.boolean))
#expect(result == "g_value_get_boolean(&value).map { $0 != 0 }")
}
@Test("Optional boolean return is mapped with != 0")
func testOptionalBooleanReturnMapping() {
let result = CodeGenerator.wrapCReturnValue(callExpression: "g_value_get_boolean(&value)", returnType: .optional(.boolean))
#expect(result == "g_value_get_boolean(&value).map { $0 != 0 }")
}
@Test("Class with subclasses is not final")
func testClassWithSubclassesIsNotFinal() throws {
let repo = Repository(namespaces: [
Namespace(name: "Gtk", version: "4.0", classes: [
Class(name: "Widget", cType: "GtkWidget", parent: "GObject.InitiallyUnowned"),
Class(name: "Button", cType: "GtkButton", parent: "Widget"),
])
])
let config = GenerationConfig(library: "Gtk", version: "4.0", girsDirectories: [],
targetDirectory: "", externalLibraries: [],
generate: ["Gtk.Widget", "Gtk.Button"], manual: [], ignore: [], objects: [])
let generator = CodeGenerator(config: config)
let files = try generator.generateFiles(repository: repo, analysis: Analyzer(config: config).analyze(repository: repo))
let widgetOutput = files["Widget.swift"] ?? ""
let buttonOutput = files["Button.swift"] ?? ""
// Widget has a subclass (Button), so it should NOT be final
#expect(widgetOutput.contains("open class Widget"))
#expect(!widgetOutput.contains("final class Widget"))
// Button has no subclasses, so it CAN be final
#expect(buttonOutput.contains("final class Button"))
}
@Test("Cross-namespace child does not force open class")
func testCrossNamespaceChildDoesNotForceOpen() throws {
// Base is in GObject namespace. Derived is in Gtk namespace (cross-namespace).
// Base should remain `final class` since the child is in a different namespace.
let repo = Repository(namespaces: [
Namespace(name: "GObject", version: "2.0", classes: [
Class(name: "Base", cType: "GBase", parent: nil),
]),
Namespace(name: "Gtk", version: "4.0", classes: [
Class(name: "Derived", cType: "GtkDerived", parent: "GObject.Base"),
]),
])
let config = GenerationConfig(library: "GObject", version: "2.0", girsDirectories: [],
targetDirectory: "", externalLibraries: [],
generate: ["GObject.Base"], manual: [], ignore: [], objects: [])
let generator = CodeGenerator(config: config)
let files = try generator.generateFiles(repository: repo, analysis: Analyzer(config: config).analyze(repository: repo))
let baseOutput = files["Base.swift"] ?? ""
// Base has a child (Derived) but in a different namespace, so it stays final
#expect(baseOutput.contains("final class Base"))
}