Complete filename/naming remediation with PascalCase convention
This commit is contained in:
parent
b61512c4eb
commit
321b42f9af
7 changed files with 429 additions and 17 deletions
|
|
@ -153,6 +153,10 @@ public enum SkipReason: String, Codable, CaseIterable, Sendable {
|
|||
case configIgnored
|
||||
/// A property type has no supported GValue accessor category.
|
||||
case unsupportedGValueCategory
|
||||
/// The symbol's Swift name collides with another symbol in the same
|
||||
/// module after case conversion (e.g. `CSET_A_2_Z` and `CSET_a_2_z`
|
||||
/// both map to `csetA2Z`); the first occurrence wins.
|
||||
case nameCollision
|
||||
}
|
||||
|
||||
/// A single skipped symbol: what was skipped, and why.
|
||||
|
|
@ -344,8 +348,11 @@ public struct BitfieldPlan: Equatable, Sendable {
|
|||
|
||||
/// The plan for a global constant.
|
||||
public struct ConstantPlan: Equatable, Sendable {
|
||||
/// The Swift name of the constant, e.g. `"MAJOR_VERSION"`.
|
||||
/// The Swift name of the constant, e.g. `"majorVersion"`.
|
||||
public let name: String
|
||||
/// The original GIR constant name, e.g. `"MAJOR_VERSION"`. Rendered into
|
||||
/// the doc comment so the C spelling stays greppable after the rename.
|
||||
public let girName: String
|
||||
/// The literal value as a string, e.g. `"2"`.
|
||||
public let value: String
|
||||
/// The Swift type of the constant, e.g. `"Int"`.
|
||||
|
|
@ -353,8 +360,9 @@ public struct ConstantPlan: Equatable, Sendable {
|
|||
/// Documentation from the GIR `<doc>` element.
|
||||
public let doc: String?
|
||||
|
||||
public init(name: String, value: String, swiftType: String, doc: String? = nil) {
|
||||
self.name = name; self.value = value; self.swiftType = swiftType; self.doc = doc
|
||||
public init(name: String, girName: String, value: String, swiftType: String, doc: String? = nil) {
|
||||
self.name = name; self.girName = girName; self.value = value
|
||||
self.swiftType = swiftType; self.doc = doc
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -520,7 +528,6 @@ public struct CallablePlan: Equatable, Sendable {
|
|||
self.throwsError = throwsError; self.doc = doc
|
||||
}
|
||||
}
|
||||
|
||||
/// How a constructor takes ownership of the new GObject instance.
|
||||
public enum OwnershipInit: String, Equatable, Sendable {
|
||||
/// `transfer-ownership="full"`: store the pointer, no additional ref.
|
||||
|
|
|
|||
|
|
@ -11,9 +11,22 @@ import Foundation
|
|||
|
||||
/// Renders a module plan to a dictionary of filename → Swift source content.
|
||||
///
|
||||
/// Each type gets its own file (e.g. `"Align.swift"`). The renderer does
|
||||
/// NOT produce scaffolding files (Package.swift, module maps, umbrella
|
||||
/// headers) — those come from `CodeGen+Scaffolding.swift` as before.
|
||||
/// File layout follows Swift conventions, decoupled from symbol names:
|
||||
/// - Each *type* (class, record, enum, bitfield, interface, alias) gets its
|
||||
/// own file named after the type (`"Align.swift"` — GIR type names are
|
||||
/// already PascalCase).
|
||||
/// - All module-level free functions merge into one `Functions.swift`, and
|
||||
/// all constants into one `Constants.swift`, each with `// MARK:` sections
|
||||
/// grouped by leading name word. One file per lowerCamelCase symbol
|
||||
/// produced hundreds of non-PascalCase filenames
|
||||
/// (`boxedFree.swift`, `PARAM_MASK.swift`) and made modules unnavigable.
|
||||
///
|
||||
/// Every emitted filename is checked against `isValidGeneratedFileName`;
|
||||
/// a violation is a generator bug and traps immediately rather than landing
|
||||
/// in a generated package.
|
||||
///
|
||||
/// The renderer does NOT produce scaffolding files (Package.swift, module
|
||||
/// maps, umbrella headers) — those come from `CodeGen+Scaffolding.swift`.
|
||||
///
|
||||
/// - Parameter plan: The completed module plan.
|
||||
/// - Returns: A dictionary of relative file path → source content.
|
||||
|
|
@ -28,18 +41,88 @@ public func renderModule(_ plan: ModulePlan) -> [String: String] {
|
|||
|
||||
"""
|
||||
|
||||
var constants: [(name: String, body: String)] = []
|
||||
var functions: [(name: String, body: String)] = []
|
||||
|
||||
for typePlan in plan.types {
|
||||
let (baseName, body) = renderTypePlan(typePlan)
|
||||
let content = header + body + "\n"
|
||||
files["\(baseName).swift"] = content
|
||||
switch typePlan {
|
||||
case .constant(let p):
|
||||
constants.append((p.name, renderConstant(p)))
|
||||
case .callable(let p):
|
||||
functions.append((p.name, renderCallable(p)))
|
||||
default:
|
||||
let (baseName, body) = renderTypePlan(typePlan)
|
||||
files["\(baseName).swift"] = header + body + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
if !constants.isEmpty {
|
||||
files["Constants.swift"] = header + mergedFileBody(constants)
|
||||
}
|
||||
if !functions.isEmpty {
|
||||
files["Functions.swift"] = header + mergedFileBody(functions)
|
||||
}
|
||||
|
||||
// Always emit the per-module pointer-cast helpers.
|
||||
files["_Support.swift"] = renderSupport(moduleName: plan.module)
|
||||
|
||||
for filename in files.keys {
|
||||
precondition(isValidGeneratedFileName(filename),
|
||||
"generated filename '\(filename)' violates the PascalCase convention")
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
/// Whether a generated filename follows the project convention: a PascalCase
|
||||
/// base name (`Align.swift`, `IOChannel.swift`) with an optional `_` prefix
|
||||
/// reserved for infrastructure files (`_Support.swift`). Underscores inside
|
||||
/// the name and uppercase runs longer than three letters (the signature of
|
||||
/// unconverted C spellings like `BOOLEANBOXED`) are rejected.
|
||||
///
|
||||
/// - Parameter filename: A relative filename ending in `.swift`.
|
||||
/// - Returns: `true` when the name is conventional.
|
||||
public func isValidGeneratedFileName(_ filename: String) -> Bool {
|
||||
guard filename.hasSuffix(".swift") else { return false }
|
||||
var base = Substring(filename.dropLast(".swift".count))
|
||||
if base.hasPrefix("_") { base = base.dropFirst() }
|
||||
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
|
||||
for ch in base {
|
||||
run = ch.isUppercase ? run + 1 : 0
|
||||
if run > 3 { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Joins pre-rendered symbol bodies into one file body, sorted by symbol name
|
||||
/// with a `// MARK: -` section heading whenever the leading name word changes
|
||||
/// (`ascii…`, `unichar…`), so merged files stay navigable in an editor's
|
||||
/// symbol outline.
|
||||
private func mergedFileBody(_ symbols: [(name: String, body: String)]) -> String {
|
||||
let sorted = symbols.sorted { $0.name.lowercased() < $1.name.lowercased() }
|
||||
var sections: [String] = []
|
||||
var currentGroup = ""
|
||||
for symbol in sorted {
|
||||
let group = leadingNameWord(symbol.name)
|
||||
if group != currentGroup {
|
||||
currentGroup = group
|
||||
sections.append("// MARK: - \(group.prefix(1).uppercased() + group.dropFirst())\n")
|
||||
}
|
||||
sections.append(symbol.body)
|
||||
}
|
||||
return sections.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Extracts the leading lowercase word of a symbol name for MARK grouping:
|
||||
/// `"unicharToUtf8"` → `"unichar"`, `"`import`"` (backtick-escaped) → `"import"`.
|
||||
private func leadingNameWord(_ name: String) -> String {
|
||||
let trimmed = name.drop(while: { $0 == "`" || $0 == "_" })
|
||||
let word = trimmed.prefix(while: { $0.isLowercase || $0.isNumber })
|
||||
return word.isEmpty ? String(trimmed) : String(word)
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
@ -193,6 +276,8 @@ private func renderConstant(_ plan: ConstantPlan) -> String {
|
|||
if let doc = plan.doc {
|
||||
lines.append(contentsOf: renderDocComment(doc))
|
||||
}
|
||||
// Keep the C spelling greppable after the lowerCamelCase rename.
|
||||
lines.append("/// Binds the GIR constant `\(plan.girName)`.")
|
||||
// String constants need quotes; numeric/literal values pass through
|
||||
let valueExpr: String
|
||||
if plan.swiftType == "String" {
|
||||
|
|
|
|||
|
|
@ -63,8 +63,14 @@ private func planNamespace(_ ns: Namespace, context: MapContext) -> ModulePlan {
|
|||
}
|
||||
|
||||
// ── Constants ──
|
||||
// Case conversion can merge distinct GIR names (`CSET_A_2_Z` and
|
||||
// `CSET_a_2_z` both become `csetA2Z`), so collisions are detected here
|
||||
// and the later occurrence is skipped.
|
||||
var seenConstantNames: Set<String> = []
|
||||
for constant in ns.constants {
|
||||
totalTypes += 1; boundTypes += planConst(into: &types, skips: &skips, constant: constant, context: context)
|
||||
totalTypes += 1
|
||||
boundTypes += planConst(into: &types, skips: &skips, seen: &seenConstantNames,
|
||||
constant: constant, context: context)
|
||||
}
|
||||
|
||||
// ── Aliases ──
|
||||
|
|
@ -93,6 +99,67 @@ private func planNamespace(_ ns: Namespace, context: MapContext) -> ModulePlan {
|
|||
boundCallables += planOrSkipFunction(into: &skips, into: &types, fn: fn, namespace: ns.name, context: context)
|
||||
}
|
||||
|
||||
// ── Cross-category name collision detection ──
|
||||
// Constants and functions can case-fold to the same Swift name
|
||||
// (e.g. ATOMIC_REF_COUNT_INIT and g_atomic_ref_count_init both map to
|
||||
// atomicRefCountInit). Functions (callables) win over constants since
|
||||
// they are typically more commonly used. Intra-category collisions
|
||||
// (constant-vs-constant, callable-vs-callable) use GIR declaration order.
|
||||
do {
|
||||
// Pass 1: collect all callable names (callables have priority)
|
||||
var callableNames: Set<String> = []
|
||||
for typePlan in types {
|
||||
if case .callable(let plan) = typePlan {
|
||||
callableNames.insert(plan.name)
|
||||
}
|
||||
}
|
||||
// Pass 2: filter with priority
|
||||
var seenNames: Set<String> = []
|
||||
var filtered: [TypePlan] = []
|
||||
var lostTypes = 0
|
||||
var lostCallables = 0
|
||||
for typePlan in types {
|
||||
switch typePlan {
|
||||
case .constant(let plan):
|
||||
// Constant loses if a callable has the same name (callables
|
||||
// have priority), or if another constant already claimed it.
|
||||
if callableNames.contains(plan.name) {
|
||||
skips.append(SkipEntry(symbol: "\(ns.name).\(plan.girName)",
|
||||
cIdentifier: plan.girName,
|
||||
reason: .nameCollision,
|
||||
detail: "Swift name '\(plan.name)' conflicts with a function in this module"))
|
||||
lostTypes += 1
|
||||
} else if seenNames.contains(plan.name) {
|
||||
skips.append(SkipEntry(symbol: "\(ns.name).\(plan.girName)",
|
||||
cIdentifier: plan.girName,
|
||||
reason: .nameCollision,
|
||||
detail: "Swift name '\(plan.name)' already taken by an earlier constant"))
|
||||
lostTypes += 1
|
||||
} else {
|
||||
seenNames.insert(plan.name)
|
||||
filtered.append(typePlan)
|
||||
}
|
||||
case .callable(let plan):
|
||||
// Callables only lose to earlier callables
|
||||
if seenNames.contains(plan.name) {
|
||||
skips.append(SkipEntry(symbol: "\(ns.name).\(plan.cIdentifier)",
|
||||
cIdentifier: plan.cIdentifier,
|
||||
reason: .nameCollision,
|
||||
detail: "Swift name '\(plan.name)' already taken by an earlier symbol"))
|
||||
lostCallables += 1
|
||||
} else {
|
||||
seenNames.insert(plan.name)
|
||||
filtered.append(typePlan)
|
||||
}
|
||||
default:
|
||||
filtered.append(typePlan)
|
||||
}
|
||||
}
|
||||
types = filtered
|
||||
boundTypes -= lostTypes
|
||||
boundCallables -= lostCallables
|
||||
}
|
||||
|
||||
let coverage = CoverageStats(
|
||||
boundCallables: boundCallables, totalCallables: totalCallables,
|
||||
boundTypes: boundTypes, totalTypes: totalTypes
|
||||
|
|
@ -128,11 +195,17 @@ private func planBit(
|
|||
}
|
||||
|
||||
private func planConst(
|
||||
into types: inout [TypePlan], skips: inout [SkipEntry],
|
||||
into types: inout [TypePlan], skips: inout [SkipEntry], seen: inout Set<String>,
|
||||
constant: Constant, context: MapContext
|
||||
) -> Int {
|
||||
let fullName = "\(context.currentNamespace).\(constant.name)"
|
||||
if let plan = planConstant(constant, context: context) {
|
||||
if !seen.insert(plan.name).inserted {
|
||||
skips.append(SkipEntry(symbol: fullName, cIdentifier: constant.name,
|
||||
reason: .nameCollision,
|
||||
detail: "Swift name '\(plan.name)' already taken by an earlier constant"))
|
||||
return 0
|
||||
}
|
||||
types.append(.constant(plan)); return 1
|
||||
}
|
||||
skips.append(SkipEntry(symbol: fullName, cIdentifier: constant.name,
|
||||
|
|
@ -514,10 +587,15 @@ func planBitfield(_ bitfield: Bitfield, context: MapContext) -> BitfieldPlan {
|
|||
|
||||
/// Plans a global constant. Returns `nil` when the constant's type cannot
|
||||
/// be mapped, so the caller records a skip entry.
|
||||
///
|
||||
/// The GIR name (`PARAM_MASK`) is converted to Swift's lowerCamelCase
|
||||
/// convention for constants (`paramMask`, like `Double.pi`); the original
|
||||
/// spelling is preserved in `girName` for the rendered doc comment.
|
||||
func planConstant(_ constant: Constant, context: MapContext) -> ConstantPlan? {
|
||||
let mappingResult = Result { try map(constant.type, nullable: false, transfer: .none, context: context) }
|
||||
guard case .success(let mapping) = mappingResult else { return nil }
|
||||
return ConstantPlan(name: constant.name, value: constant.value, swiftType: mapping.swiftType, doc: constant.doc)
|
||||
return ConstantPlan(name: swiftConstantName(constant.name), girName: constant.name,
|
||||
value: constant.value, swiftType: mapping.swiftType, doc: constant.doc)
|
||||
}
|
||||
|
||||
/// Outcome of alias planning: success with a plan, or skip with a reason.
|
||||
|
|
@ -822,13 +900,20 @@ enum ParameterPlanResult {
|
|||
/// removal is wanted — doing so was the bug that turned setters like
|
||||
/// `g_set_application_name` into `applicationName`, silently dropping the verb.
|
||||
///
|
||||
/// Fully-uppercase segments are GIR *words*, not acronyms to preserve:
|
||||
/// `cclosure_marshal_BOOLEAN__BOXED_BOXED` means "boolean, boxed, boxed" and
|
||||
/// `PARAM_MASK` means "param mask". Passing them through verbatim produced
|
||||
/// unreadable runs (`cclosureMarshalBOOLEANBOXEDBOXED`), so each segment is
|
||||
/// case-normalized to a single capitalized word (`Boolean`, `Boxed`).
|
||||
///
|
||||
/// 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)
|
||||
let parts = snake.split(separator: "_", omittingEmptySubsequences: true)
|
||||
.map { normalizeUppercaseSegment(String($0)) }
|
||||
guard let first = parts.first else { return snake }
|
||||
var result = first.lowercased()
|
||||
for word in parts.dropFirst() {
|
||||
|
|
@ -838,6 +923,18 @@ func camelCased(_ snake: String) -> String {
|
|||
return result
|
||||
}
|
||||
|
||||
/// Case-normalizes one underscore-delimited GIR segment: a segment that is
|
||||
/// entirely uppercase (letters/digits, at least two characters, e.g. `BOOLEAN`,
|
||||
/// `UINT`, `CSET`) is folded to a single capitalized word (`Boolean`, `Uint`,
|
||||
/// `Cset`); everything else passes through unchanged.
|
||||
private func normalizeUppercaseSegment(_ segment: String) -> String {
|
||||
guard segment.count >= 2,
|
||||
segment.contains(where: \.isUppercase),
|
||||
segment.allSatisfy({ $0.isUppercase || $0.isNumber })
|
||||
else { return segment }
|
||||
return segment.prefix(1) + segment.dropFirst().lowercased()
|
||||
}
|
||||
|
||||
/// Converts a GIR function/method name to a Swift name (lowerCamelCase),
|
||||
/// escaping keywords. Example: `"set_application_name"` → `"setApplicationName"`,
|
||||
/// `"show"` → `"show"`.
|
||||
|
|
@ -847,6 +944,15 @@ func swiftFunctionName(_ girName: String) -> String {
|
|||
return swiftKeywords.contains(camel) ? "`\(camel)`" : camel
|
||||
}
|
||||
|
||||
/// Converts a GIR constant name (`SCREAMING_SNAKE`) to a Swift constant name
|
||||
/// (lowerCamelCase, per the API Design Guidelines — like `Double.pi`),
|
||||
/// escaping keywords. Example: `"PARAM_MASK"` → `"paramMask"`.
|
||||
func swiftConstantName(_ 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 (lowerCamelCase),
|
||||
/// escaping keywords. Example: `"application_name"` → `"applicationName"`.
|
||||
func swiftParameterName(_ girName: String) -> String {
|
||||
|
|
|
|||
|
|
@ -230,4 +230,34 @@ struct RealGIRParsingTests {
|
|||
}
|
||||
#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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ struct FunctionGenerationTests {
|
|||
}
|
||||
|
||||
/// Renders a single planned callable to Swift source via the public module
|
||||
/// renderer, returning the callable's file body.
|
||||
/// renderer, returning the merged `Functions.swift` body it lands in.
|
||||
func render(_ plan: CallablePlan) -> String {
|
||||
let module = ModulePlan(
|
||||
module: "GLib",
|
||||
|
|
@ -38,7 +38,7 @@ struct FunctionGenerationTests {
|
|||
coverage: CoverageStats()
|
||||
)
|
||||
let files = renderModule(module)
|
||||
return files["\(plan.name).swift"] ?? ""
|
||||
return files["Functions.swift"] ?? ""
|
||||
}
|
||||
|
||||
// MARK: - Plannable callables
|
||||
|
|
|
|||
160
Tests/SwiftGtkGenCoreTests/NamingTests.swift
Normal file
160
Tests/SwiftGtkGenCoreTests/NamingTests.swift
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// NamingTests.swift
|
||||
// Locks in the generated naming conventions: symbol case conversion
|
||||
// (uppercase GIR segments fold to words, constants become lowerCamelCase),
|
||||
// the PascalCase filename rule with merged Functions.swift/Constants.swift
|
||||
// files, and collision handling when case conversion merges distinct GIR
|
||||
// names. A regression here means generated packages stop following Swift
|
||||
// naming conventions.
|
||||
|
||||
import Testing
|
||||
|
||||
@testable import SwiftGtkGenCore
|
||||
|
||||
@Suite("Naming conventions")
|
||||
struct NamingTests {
|
||||
// MARK: - Symbol case conversion
|
||||
|
||||
@Test("Uppercase GIR segments fold to single words")
|
||||
func uppercaseSegmentsNormalize() {
|
||||
// The marshal symbols are the canonical offenders: uppercase segments
|
||||
// and a double underscore that must not glue words together.
|
||||
#expect(camelCased("cclosure_marshal_BOOLEAN__BOXED_BOXED") == "cclosureMarshalBooleanBoxedBoxed")
|
||||
#expect(camelCased("cclosure_marshal_VOID__UINT_POINTER") == "cclosureMarshalVoidUintPointer")
|
||||
// Ordinary snake_case is unaffected.
|
||||
#expect(camelCased("set_application_name") == "setApplicationName")
|
||||
// Mixed-case segments pass through untouched.
|
||||
#expect(camelCased("show") == "show")
|
||||
}
|
||||
|
||||
@Test("Constants convert to lowerCamelCase")
|
||||
func constantNames() {
|
||||
#expect(swiftConstantName("PARAM_MASK") == "paramMask")
|
||||
#expect(swiftConstantName("TYPE_FLAG_RESERVED_ID_BIT") == "typeFlagReservedIdBit")
|
||||
#expect(swiftConstantName("PI_2") == "pi2")
|
||||
#expect(swiftConstantName("E") == "e")
|
||||
}
|
||||
|
||||
// MARK: - Filename convention
|
||||
|
||||
@Test("Filename validator accepts PascalCase and infra names only")
|
||||
func filenameValidation() {
|
||||
#expect(isValidGeneratedFileName("Align.swift"))
|
||||
#expect(isValidGeneratedFileName("IOChannel.swift")) // 3-cap acronym run
|
||||
#expect(isValidGeneratedFileName("FileIOStream.swift"))
|
||||
#expect(isValidGeneratedFileName("_Support.swift")) // infra prefix
|
||||
#expect(isValidGeneratedFileName("ParamSpecInt64.swift")) // digits
|
||||
|
||||
#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("Align.txt")) // wrong extension
|
||||
}
|
||||
|
||||
// MARK: - File merging
|
||||
|
||||
/// A context whose registry contains only the GLib namespace under test.
|
||||
func makeContext(_ ns: Namespace) -> MapContext {
|
||||
let repo = Repository(namespaces: [ns])
|
||||
let registry = TypeRegistry(repositories: ["GLib": repo])
|
||||
return MapContext(registry: registry, currentModule: "GLib", currentNamespace: "GLib")
|
||||
}
|
||||
|
||||
/// Plans a synthetic namespace through the public `planModules` entry
|
||||
/// point and returns its module plan.
|
||||
func plan(_ ns: Namespace) -> ModulePlan {
|
||||
let repo = Repository(namespaces: [ns])
|
||||
let analysis = MultiPackageAnalysis(
|
||||
repositories: ["GLib": repo],
|
||||
directDependencies: [:], transitiveDependencies: [:],
|
||||
implicitImports: [:], packageConfigs: [:]
|
||||
)
|
||||
let registry = TypeRegistry(repositories: ["GLib": repo])
|
||||
return planModules(analysis: analysis, registry: registry)["GLib"]!
|
||||
}
|
||||
|
||||
@Test("Free functions merge into Functions.swift with MARK sections")
|
||||
func functionsMerge() {
|
||||
let ns = Namespace(
|
||||
name: "GLib", version: "2.0",
|
||||
functions: [
|
||||
GlobalFunction(name: "ascii_strup", cIdentifier: "g_ascii_strup",
|
||||
parameters: [Parameter(name: "str", type: .string, cType: "const gchar*")],
|
||||
returnValue: ReturnValue(type: .string, transferOwnership: .full)),
|
||||
GlobalFunction(name: "unichar_istitle", cIdentifier: "g_unichar_istitle",
|
||||
parameters: [Parameter(name: "c", type: .unichar, cType: "gunichar")],
|
||||
returnValue: ReturnValue(type: .boolean)),
|
||||
]
|
||||
)
|
||||
let files = renderModule(plan(ns))
|
||||
let source = files["Functions.swift"] ?? ""
|
||||
#expect(files["asciiStrup.swift"] == nil)
|
||||
#expect(source.contains("public func asciiStrup"))
|
||||
#expect(source.contains("public func unicharIstitle"))
|
||||
#expect(source.contains("// MARK: - Ascii"))
|
||||
#expect(source.contains("// MARK: - Unichar"))
|
||||
}
|
||||
|
||||
@Test("Constants merge into Constants.swift as lowerCamelCase with C name in docs")
|
||||
func constantsMerge() {
|
||||
let ns = Namespace(
|
||||
name: "GLib", version: "2.0",
|
||||
constants: [Constant(name: "PARAM_MASK", value: "255", type: .int32)]
|
||||
)
|
||||
let files = renderModule(plan(ns))
|
||||
let source = files["Constants.swift"] ?? ""
|
||||
#expect(files["PARAM_MASK.swift"] == nil)
|
||||
#expect(source.contains("public nonisolated let paramMask: Int32 = 255"))
|
||||
#expect(source.contains("/// Binds the GIR constant `PARAM_MASK`."))
|
||||
}
|
||||
|
||||
@Test("Case-conversion collisions keep the first constant and skip the rest")
|
||||
func constantCollision() {
|
||||
// GLib really has this pair: both convert to `csetA2Z`.
|
||||
let ns = Namespace(
|
||||
name: "GLib", version: "2.0",
|
||||
constants: [
|
||||
Constant(name: "CSET_A_2_Z", value: "ABC", type: .string),
|
||||
Constant(name: "CSET_a_2_z", value: "abc", type: .string),
|
||||
]
|
||||
)
|
||||
let modulePlan = plan(ns)
|
||||
let constants = modulePlan.types.compactMap { plan -> ConstantPlan? in
|
||||
if case .constant(let c) = plan { return c } else { return nil }
|
||||
}
|
||||
#expect(constants.map(\.name) == ["csetA2Z"])
|
||||
#expect(constants.first?.girName == "CSET_A_2_Z")
|
||||
let collisions = modulePlan.skips.filter { $0.reason == .nameCollision }
|
||||
#expect(collisions.map(\.cIdentifier) == ["CSET_a_2_z"])
|
||||
}
|
||||
|
||||
@Test("Cross-category collision: constant and function folding to same Swift name")
|
||||
func constantFunctionCollision() {
|
||||
// GLib has ATOMIC_REF_COUNT_INIT (constant) and
|
||||
// atomic_ref_count_init (function) — both map to atomicRefCountInit.
|
||||
// Functions win over constants, so the constant is skipped.
|
||||
let ns = Namespace(
|
||||
name: "GLib", version: "2.0",
|
||||
functions: [
|
||||
GlobalFunction(name: "atomic_ref_count_init", cIdentifier: "g_atomic_ref_count_init",
|
||||
parameters: [], returnValue: ReturnValue(type: .void)),
|
||||
],
|
||||
constants: [
|
||||
Constant(name: "ATOMIC_REF_COUNT_INIT", value: "1", type: .int32),
|
||||
]
|
||||
)
|
||||
let modulePlan = plan(ns)
|
||||
let constants = modulePlan.types.compactMap { plan -> ConstantPlan? in
|
||||
if case .constant(let c) = plan { return c } else { return nil }
|
||||
}
|
||||
let callables = modulePlan.types.compactMap { plan -> CallablePlan? in
|
||||
if case .callable(let fn) = plan { return fn } else { return nil }
|
||||
}
|
||||
#expect(callables.map(\.name) == ["atomicRefCountInit"])
|
||||
#expect(callables.first?.cIdentifier == "g_atomic_ref_count_init")
|
||||
#expect(constants.isEmpty)
|
||||
let collisions = modulePlan.skips.filter { $0.reason == .nameCollision }
|
||||
#expect(collisions.map(\.cIdentifier) == ["ATOMIC_REF_COUNT_INIT"])
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
{
|
||||
"entries" : [
|
||||
{
|
||||
"cIdentifier" : "ATOMIC_REF_COUNT_INIT",
|
||||
"detail" : "Swift name 'atomicRefCountInit' conflicts with a function in this module",
|
||||
"reason" : "nameCollision",
|
||||
"symbol" : "GLib.ATOMIC_REF_COUNT_INIT"
|
||||
},
|
||||
{
|
||||
"cIdentifier" : "GAllocator",
|
||||
"detail" : "no GType registration",
|
||||
|
|
@ -18,6 +24,12 @@
|
|||
"reason" : "plainRecord",
|
||||
"symbol" : "GLib.AsyncQueue"
|
||||
},
|
||||
{
|
||||
"cIdentifier" : "CSET_a_2_z",
|
||||
"detail" : "Swift name 'csetA2Z' already taken by an earlier constant",
|
||||
"reason" : "nameCollision",
|
||||
"symbol" : "GLib.CSET_a_2_z"
|
||||
},
|
||||
{
|
||||
"cIdentifier" : "GCache",
|
||||
"detail" : "no GType registration",
|
||||
|
|
@ -402,6 +414,12 @@
|
|||
"reason" : "plainRecord",
|
||||
"symbol" : "GLib.Queue"
|
||||
},
|
||||
{
|
||||
"cIdentifier" : "REF_COUNT_INIT",
|
||||
"detail" : "Swift name 'refCountInit' conflicts with a function in this module",
|
||||
"reason" : "nameCollision",
|
||||
"symbol" : "GLib.REF_COUNT_INIT"
|
||||
},
|
||||
{
|
||||
"cIdentifier" : "GRWLock",
|
||||
"detail" : "no GType registration",
|
||||
|
|
@ -432,6 +450,12 @@
|
|||
"reason" : "plainRecord",
|
||||
"symbol" : "GLib.SList"
|
||||
},
|
||||
{
|
||||
"cIdentifier" : "SOURCE_REMOVE",
|
||||
"detail" : "Swift name 'sourceRemove' conflicts with a function in this module",
|
||||
"reason" : "nameCollision",
|
||||
"symbol" : "GLib.SOURCE_REMOVE"
|
||||
},
|
||||
{
|
||||
"cIdentifier" : "GScanner",
|
||||
"detail" : "no GType registration",
|
||||
|
|
@ -3076,7 +3100,7 @@
|
|||
"module" : "GLib",
|
||||
"stats" : {
|
||||
"boundCallables" : 333,
|
||||
"boundTypes" : 246,
|
||||
"boundTypes" : 242,
|
||||
"totalCallables" : 724,
|
||||
"totalTypes" : 367
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue