263 lines
12 KiB
Swift
263 lines
12 KiB
Swift
// RealGIRParsingTests.swift
|
|
// Cross-checks the parser against the system's real .gir files. Counts are
|
|
// verified against independently-derived ground truth (grep over the same XML)
|
|
// so that a parser regression that silently drops constructs is caught here
|
|
// rather than surfacing as mysterious missing bindings downstream.
|
|
|
|
import Foundation
|
|
import Testing
|
|
|
|
@testable import GObjectGeneratorCore
|
|
|
|
@Suite("Real GIR parsing")
|
|
struct RealGIRParsingTests {
|
|
/// Directory holding the system's GObject Introspection files.
|
|
nonisolated static let girDirectory = "/usr/share/gir-1.0"
|
|
|
|
/// Whether a given GIR file is present on this system.
|
|
///
|
|
/// Callable from `@Test` availability traits, which run outside the
|
|
/// package's default MainActor isolation.
|
|
///
|
|
/// - Parameter name: The GIR file name, e.g. `"Gtk-4.0.gir"`.
|
|
/// - Returns: `true` when the file exists and tests depending on it can run.
|
|
nonisolated static func hasGIR(_ name: String) -> Bool {
|
|
FileManager.default.fileExists(atPath: "\(girDirectory)/\(name)")
|
|
}
|
|
|
|
/// Parses a system GIR file.
|
|
///
|
|
/// - Parameter name: The GIR file name, e.g. `"Gtk-4.0.gir"`.
|
|
/// - Returns: The parsed repository.
|
|
/// - Throws: A `GIRParserError` if parsing fails.
|
|
static func parse(_ name: String) throws -> Repository {
|
|
try GIRParser().parse(fileURL: URL(fileURLWithPath: "\(girDirectory)/\(name)"))
|
|
}
|
|
|
|
/// Counts occurrences of a literal substring in a GIR file, as independent
|
|
/// ground truth for what the parser should have found.
|
|
///
|
|
/// Mirrors `grep -c` semantics for the single-occurrence-per-line markup
|
|
/// these attributes appear in.
|
|
///
|
|
/// - Parameters:
|
|
/// - needle: The literal substring to count, e.g. `"direction=\"out\""`.
|
|
/// - file: The GIR file name.
|
|
/// - Returns: The number of lines containing the substring.
|
|
static func groundTruthCount(of needle: String, in file: String) throws -> Int {
|
|
let text = try String(contentsOfFile: "\(girDirectory)/\(file)", encoding: .utf8)
|
|
return text.split(separator: "\n").count { $0.contains(needle) }
|
|
}
|
|
|
|
@Test(.enabled(if: hasGIR("GLib-2.0.gir")))
|
|
func parsesGLib() throws {
|
|
let repo = try Self.parse("GLib-2.0.gir")
|
|
let ns = try #require(repo.namespaces.first { $0.name == "GLib" })
|
|
#expect(ns.version == "2.0")
|
|
#expect(ns.records.count > 50)
|
|
#expect(ns.functions.count > 100)
|
|
#expect(ns.enumerations.count > 10)
|
|
|
|
// Every record field must have a real type. `.void` fields were the
|
|
// signature of the old parser's dropped <type> routing.
|
|
let voidFields = ns.records.flatMap { record in
|
|
record.fields.filter { $0.type == .void }.map { "\(record.name).\($0.name)" }
|
|
}
|
|
#expect(voidFields.isEmpty, "record fields with no type: \(voidFields.prefix(5))")
|
|
}
|
|
|
|
@Test(.enabled(if: hasGIR("Gtk-4.0.gir")))
|
|
func parsesGtkMatchingGroundTruth() throws {
|
|
let repo = try Self.parse("Gtk-4.0.gir")
|
|
let ns = try #require(repo.namespaces.first { $0.name == "Gtk" })
|
|
|
|
#expect(ns.classes.count == (try Self.groundTruthCount(of: "<class ", in: "Gtk-4.0.gir")))
|
|
#expect(ns.records.count == (try Self.groundTruthCount(of: "<record ", in: "Gtk-4.0.gir")))
|
|
|
|
// GObject type structs (GtkWidgetClass and friends) dominate the record
|
|
// list and must be identified so the planner can skip them.
|
|
let typeStructs = ns.records.filter { $0.isGTypeStructFor != nil }
|
|
#expect(typeStructs.count == (try Self.groundTruthCount(of: "glib:is-gtype-struct-for", in: "Gtk-4.0.gir")))
|
|
#expect(typeStructs.count > ns.records.count / 2)
|
|
}
|
|
|
|
@Test(.enabled(if: hasGIR("Gtk-4.0.gir")))
|
|
func capturesOutParametersAndThrows() throws {
|
|
let repo = try Self.parse("Gtk-4.0.gir")
|
|
let ns = try #require(repo.namespaces.first { $0.name == "Gtk" })
|
|
|
|
/// Every callable reachable from the namespace, reduced to the two
|
|
/// facts under test: its parameters and whether it throws.
|
|
var parameterLists: [[Parameter]] = []
|
|
var throwsFlags: [Bool] = []
|
|
func record(_ parameters: [Parameter], _ throwsGError: Bool) {
|
|
parameterLists.append(parameters)
|
|
throwsFlags.append(throwsGError)
|
|
}
|
|
for cls in ns.classes {
|
|
for m in cls.methods { record(m.parameters, m.throwsGError) }
|
|
for c in cls.constructors { record(c.parameters, c.throwsGError) }
|
|
for f in cls.functions { record(f.parameters, f.throwsGError) }
|
|
}
|
|
for iface in ns.interfaces {
|
|
for m in iface.methods { record(m.parameters, m.throwsGError) }
|
|
}
|
|
for rec in ns.records {
|
|
for m in rec.methods { record(m.parameters, m.throwsGError) }
|
|
}
|
|
for f in ns.functions { record(f.parameters, f.throwsGError) }
|
|
|
|
let outParams = parameterLists.flatMap { $0 }.count { $0.direction == .out }
|
|
#expect(outParams > 300, "expected GTK's out-parameters to be parsed, found \(outParams)")
|
|
|
|
let throwing = throwsFlags.count { $0 }
|
|
#expect(throwing > 30, "expected GTK's throwing callables to be parsed, found \(throwing)")
|
|
|
|
// gtk_widget_measure is the canonical multi-out-parameter method.
|
|
let widget = try #require(ns.classes.first { $0.name == "Widget" })
|
|
let measure = try #require(widget.methods.first { $0.name == "measure" })
|
|
#expect(measure.parameters.count { $0.direction == .out } == 4)
|
|
}
|
|
|
|
@Test(.enabled(if: hasGIR("Gtk-4.0.gir")))
|
|
func capturesClassHierarchyAndGTypes() throws {
|
|
let repo = try Self.parse("Gtk-4.0.gir")
|
|
let ns = try #require(repo.namespaces.first { $0.name == "Gtk" })
|
|
|
|
let widget = try #require(ns.classes.first { $0.name == "Widget" })
|
|
#expect(widget.parent == "GObject.InitiallyUnowned")
|
|
#expect(widget.isAbstract)
|
|
#expect(widget.getTypeFunction == "gtk_widget_get_type")
|
|
#expect(widget.implements.contains("Accessible"))
|
|
|
|
// Every class must carry its GType registration: the registry needs it
|
|
// to classify types and drive GValue access.
|
|
let missingGType = ns.classes.filter { $0.getTypeFunction == nil }.map(\.name)
|
|
#expect(missingGType.isEmpty, "classes missing glib:get-type: \(missingGType.prefix(5))")
|
|
}
|
|
|
|
/// The registry must link the real GTK stack across module boundaries.
|
|
/// Under the old generator this chain was severed at every namespace
|
|
/// boundary, so `Gtk.Widget` did not inherit from `GObject.Object` at all.
|
|
@Test(
|
|
.enabled(
|
|
if: hasGIR("Gtk-4.0.gir") && hasGIR("Gio-2.0.gir")
|
|
&& hasGIR("GObject-2.0.gir") && hasGIR("GLib-2.0.gir")))
|
|
func registryLinksRealGtkStackAcrossModules() throws {
|
|
// Mirrors the tier configs: Gtk's stack is only coherent with Gio
|
|
// present, since Gtk.Application inherits Gio.Application.
|
|
let registry = TypeRegistry(repositories: [
|
|
"GLib": try Self.parse("GLib-2.0.gir"),
|
|
"GObject": try Self.parse("GObject-2.0.gir"),
|
|
"Gio": try Self.parse("Gio-2.0.gir"),
|
|
"Gtk": try Self.parse("Gtk-4.0.gir"),
|
|
])
|
|
|
|
let ancestry = registry.ancestry(of: "Gtk.Button").map(\.girName)
|
|
#expect(ancestry.contains("Gtk.Widget"))
|
|
#expect(ancestry.contains("GObject.InitiallyUnowned"))
|
|
#expect(ancestry.last == "GObject.Object")
|
|
|
|
// Floating-reference rule: widgets sink, plain GObjects do not.
|
|
let buttonFloats = registry.descendsFromInitiallyUnowned("Gtk.Button")
|
|
let appFloats = registry.descendsFromInitiallyUnowned("Gtk.Application")
|
|
#expect(buttonFloats)
|
|
#expect(!appFloats)
|
|
#expect(registry.isGObject("Gtk.Application"))
|
|
#expect(registry.ancestry(of: "Gtk.Application").map(\.girName).contains("Gio.Application"))
|
|
|
|
// Cross-module `open` requirement: GObject.Object is subclassed only
|
|
// from other modules, and must still be open.
|
|
let subclassed = registry.subclassedTypes()
|
|
#expect(subclassed.contains("GObject.Object"))
|
|
#expect(subclassed.contains("GObject.InitiallyUnowned"))
|
|
|
|
// Gtk includes Gdk and Gsk; with neither loaded, both must resolve as
|
|
// foreign so callables touching them skip cleanly rather than emitting
|
|
// fabricated type references.
|
|
let foreign = registry.foreign
|
|
#expect(foreign.contains("Gdk"))
|
|
#expect(foreign.contains("Gsk"))
|
|
let surface = registry.resolve(.typeRef("Surface", namespace: "Gdk"), from: "Gtk")
|
|
#expect(surface?.category == .foreign(namespace: "Gdk"))
|
|
}
|
|
|
|
/// cairo ships no GIR on this system, but Gdk references it. Loading Gdk
|
|
/// must therefore surface cairo as foreign rather than as an unknown type.
|
|
@Test(.enabled(if: hasGIR("Gdk-4.0.gir") && hasGIR("GObject-2.0.gir") && hasGIR("GLib-2.0.gir")))
|
|
func cairoResolvesAsForeignWhenGdkIsLoaded() throws {
|
|
let registry = TypeRegistry(repositories: [
|
|
"GLib": try Self.parse("GLib-2.0.gir"),
|
|
"GObject": try Self.parse("GObject-2.0.gir"),
|
|
"Gdk": try Self.parse("Gdk-4.0.gir"),
|
|
])
|
|
let foreign = registry.foreign
|
|
#expect(foreign.contains("cairo"))
|
|
let context = registry.resolve(.typeRef("Context", namespace: "cairo"), from: "Gdk")
|
|
#expect(context?.category == .foreign(namespace: "cairo"))
|
|
}
|
|
|
|
/// Every class parent named by the real GIRs must resolve. An unresolvable
|
|
/// parent means a fabricated superclass reference in generated code.
|
|
@Test(
|
|
.enabled(
|
|
if: hasGIR("Gtk-4.0.gir") && hasGIR("Gio-2.0.gir")
|
|
&& hasGIR("GObject-2.0.gir") && hasGIR("GLib-2.0.gir")))
|
|
func everyRealGtkClassParentResolves() throws {
|
|
let repos: [String: Repository] = [
|
|
"GLib": try Self.parse("GLib-2.0.gir"),
|
|
"GObject": try Self.parse("GObject-2.0.gir"),
|
|
"Gio": try Self.parse("Gio-2.0.gir"),
|
|
"Gtk": try Self.parse("Gtk-4.0.gir"),
|
|
]
|
|
let registry = TypeRegistry(repositories: repos)
|
|
let ns = try #require(repos["Gtk"]?.namespaces.first { $0.name == "Gtk" })
|
|
|
|
let unresolved = ns.classes.compactMap { cls -> String? in
|
|
guard let parent = cls.parent else { return nil }
|
|
let qualified = parent.contains(".") ? parent : "Gtk.\(parent)"
|
|
return registry.resolve(girName: qualified) == nil ? "\(cls.name) -> \(parent)" : nil
|
|
}
|
|
#expect(unresolved.isEmpty, "unresolvable class parents: \(unresolved.prefix(5))")
|
|
}
|
|
|
|
@Test(.enabled(if: hasGIR("Gtk-4.0.gir")))
|
|
func capturesNonIntrospectableSymbols() throws {
|
|
let repo = try Self.parse("Gtk-4.0.gir")
|
|
let ns = try #require(repo.namespaces.first { $0.name == "Gtk" })
|
|
let nonIntrospectable = ns.classes.flatMap { cls in
|
|
cls.methods.filter { !$0.symbolInfo.isIntrospectable }
|
|
}
|
|
#expect(!nonIntrospectable.isEmpty, "GTK has non-introspectable methods that must be recognized")
|
|
}
|
|
|
|
/// Every filename rendered from the real tier-1 GIRs must follow the
|
|
/// PascalCase convention — the end-to-end lock that keeps future codegen
|
|
/// from reintroducing symbol-named files (`boxedFree.swift`,
|
|
/// `PARAM_MASK.swift`, `cclosureMarshalBOOLEANFLAGS.swift`).
|
|
@Test(.enabled(if: hasGIR("GLib-2.0.gir") && hasGIR("GObject-2.0.gir")))
|
|
func generatedFilenamesArePascalCase() throws {
|
|
let repositories = [
|
|
"GLib": try Self.parse("GLib-2.0.gir"),
|
|
"GObject": try Self.parse("GObject-2.0.gir"),
|
|
]
|
|
let analysis = MultiPackageAnalysis(
|
|
repositories: repositories,
|
|
directDependencies: [:], transitiveDependencies: [:],
|
|
implicitImports: [:], packageConfigs: [:]
|
|
)
|
|
let registry = TypeRegistry(repositories: repositories)
|
|
let plans = planModules(analysis: analysis, registry: registry)
|
|
#expect(plans.count == 2)
|
|
for (module, plan) in plans {
|
|
let files = renderModule(plan)
|
|
let violations = files.keys.filter { !isValidGeneratedFileName($0) }
|
|
#expect(violations.isEmpty,
|
|
"\(module) emitted non-PascalCase filenames: \(violations.sorted())")
|
|
// The merge targets must actually exist — an empty module would
|
|
// vacuously pass the check above.
|
|
#expect(files["Functions.swift"] != nil)
|
|
#expect(files["Constants.swift"] != nil)
|
|
}
|
|
}
|
|
}
|