545 lines
21 KiB
Swift
545 lines
21 KiB
Swift
import Foundation
|
|
import SwiftParser
|
|
import SwiftSyntax
|
|
|
|
/// Normalizes an inheritance-clause type string for protocol inheritance clauses
|
|
/// that read `: @MainActor Widget` while class clauses read `: PreferencesRow`.
|
|
func normalizeInherited(_ text: String) -> String {
|
|
var t = text.trimmingCharacters(in: .whitespaces)
|
|
// Drop a leading `@MainActor ` token inserted by the generated wrappers.
|
|
if t.hasPrefix("@MainActor ") {
|
|
t.removeFirst("@MainActor ".count)
|
|
}
|
|
return t
|
|
}
|
|
|
|
/// Extracts ``WidgetModel``s from a gtk-swift generated wrapper file.
|
|
///
|
|
/// Every file has exactly one public class (verified across all 573 generated files)
|
|
/// that becomes a `kind: .widgetClass` model. Files that also declare a public
|
|
/// `Widget`-refining protocol and its extension members produce a second model
|
|
/// with `kind: .interface`.
|
|
func parseWidgets(filePath: String) throws -> [WidgetModel] {
|
|
let source = try String(contentsOfFile: filePath, encoding: .utf8)
|
|
let sourceFile = Parser.parse(source: source)
|
|
|
|
let module = inferModule(from: filePath)
|
|
var results: [WidgetModel] = []
|
|
|
|
// ── Class model (one per file) ────────────────────────────────────
|
|
if let classDecl = findPublicClass(in: sourceFile) {
|
|
let className = classDecl.name.text
|
|
var docLinesVal = docLines(from: classDecl.leadingTrivia)
|
|
if docLinesVal.isEmpty {
|
|
// Class doc sits above the first @_cdecl("_trampoline…") function
|
|
// in 121 of 359 upstream classes. Walk statements before the class.
|
|
for stmt in sourceFile.statements {
|
|
if stmt.item.as(ClassDeclSyntax.self) != nil { break }
|
|
guard let funcDecl = stmt.item.as(FunctionDeclSyntax.self),
|
|
funcDecl.name.text.hasPrefix("_trampoline") else { continue }
|
|
let dl = docLines(from: funcDecl.leadingTrivia)
|
|
if !dl.isEmpty { docLinesVal = dl; break }
|
|
}
|
|
}
|
|
|
|
let parentClass: String
|
|
if let inherited = classDecl.inheritanceClause?.inheritedTypes.first {
|
|
parentClass = normalizeInherited(inherited.type.description)
|
|
} else {
|
|
parentClass = "GObject"
|
|
}
|
|
|
|
let members = classDecl.memberBlock.members
|
|
let memberDocs = extractMemberDocs(from: members)
|
|
let inits = extractInits(from: members)
|
|
let noArgInit = extractNoArgInit(in: members)
|
|
let properties = extractProperties(from: members)
|
|
let signals = extractSignals(from: members)
|
|
let childAdders = extractChildAdders(from: members)
|
|
|
|
results.append(WidgetModel(
|
|
className: className,
|
|
module: module,
|
|
kind: .widgetClass,
|
|
docLines: docLinesVal,
|
|
inits: inits,
|
|
noArgInit: noArgInit,
|
|
properties: properties,
|
|
signals: signals,
|
|
childAdders: childAdders,
|
|
parentClass: parentClass,
|
|
memberDocs: memberDocs
|
|
))
|
|
}
|
|
|
|
// ── Interface models (Widget-refining protocols with extension members) ──
|
|
for stmt in sourceFile.statements {
|
|
guard let protoDecl = stmt.item.as(ProtocolDeclSyntax.self),
|
|
protoDecl.modifiers.contains(where: { ["public", "open"].contains($0.name.text) })
|
|
else { continue }
|
|
|
|
// Does this protocol refine Widget?
|
|
guard let inherited = protoDecl.inheritanceClause?.inheritedTypes.first else { continue }
|
|
let base = normalizeInherited(inherited.type.description)
|
|
guard base == "Widget" || base == "Gtk.Widget" || base == "Adw.Widget" else { continue }
|
|
|
|
let protoName = protoDecl.name.text
|
|
let protoDocLines = docLines(from: protoDecl.leadingTrivia)
|
|
|
|
// Gather extension members from the same file.
|
|
var extMembers: MemberBlockItemListSyntax? = nil
|
|
for extStmt in sourceFile.statements {
|
|
guard let extDecl = extStmt.item.as(ExtensionDeclSyntax.self) else { continue }
|
|
guard extDecl.extendedType.as(IdentifierTypeSyntax.self)?.name.text == protoName else { continue }
|
|
extMembers = extDecl.memberBlock.members
|
|
break
|
|
}
|
|
|
|
let properties: [PropertyModel]
|
|
let signals: [SignalModel]
|
|
let memberDocs: [String: [String]]
|
|
if let em = extMembers {
|
|
properties = extractProperties(from: em)
|
|
signals = extractSignals(from: em)
|
|
memberDocs = extractMemberDocs(from: em)
|
|
} else {
|
|
properties = []
|
|
signals = []
|
|
memberDocs = [:]
|
|
}
|
|
|
|
results.append(WidgetModel(
|
|
className: protoName,
|
|
module: module,
|
|
kind: .interface,
|
|
docLines: protoDocLines,
|
|
inits: [],
|
|
noArgInit: nil,
|
|
properties: properties,
|
|
signals: signals,
|
|
childAdders: [],
|
|
parentClass: base,
|
|
memberDocs: memberDocs
|
|
))
|
|
}
|
|
|
|
if results.isEmpty { throw ParseError.noClassFound(filePath) }
|
|
return results
|
|
}
|
|
|
|
// MARK: - Module inference
|
|
|
|
func inferModule(from path: String) -> String {
|
|
// Expect path to contain "Sources/<Module>/Generated/"
|
|
let components = path.split(separator: "/")
|
|
if let srcIdx = components.firstIndex(of: "Sources"),
|
|
srcIdx + 2 < components.count,
|
|
components[srcIdx + 2] == "Generated"
|
|
{
|
|
return String(components[srcIdx + 1])
|
|
}
|
|
// Try parent directory name
|
|
let dir = (path as NSString).deletingLastPathComponent
|
|
let dirName = (dir as NSString).lastPathComponent
|
|
if dirName == "Generated" {
|
|
let grandparent = (dir as NSString).deletingLastPathComponent
|
|
return (grandparent as NSString).lastPathComponent
|
|
}
|
|
return dirName
|
|
}
|
|
|
|
func findPublicClass(in node: some SyntaxProtocol) -> ClassDeclSyntax? {
|
|
for stmt in node.children(viewMode: .all) {
|
|
if let cd = stmt.as(ClassDeclSyntax.self),
|
|
cd.modifiers.contains(where: { ["public", "open"].contains($0.name.text) })
|
|
{
|
|
return cd
|
|
}
|
|
if let found = findPublicClass(in: stmt) {
|
|
return found
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Doc comment
|
|
|
|
/// Extracts every documentation line from `trivia` in source order.
|
|
///
|
|
/// Strips the `///` marker and at most one following space, so nested
|
|
/// indentation (list continuations, code fences, HTML) survives verbatim.
|
|
/// A bare `///` yields an empty string. Returns `[]` when there is no doc.
|
|
func docLines(from trivia: Trivia) -> [String] {
|
|
var lines: [String] = []
|
|
for piece in trivia {
|
|
guard case .docLineComment(let text) = piece else { continue }
|
|
let raw = text.dropFirst(3) // "///"
|
|
let line = raw.hasPrefix(" ") ? String(raw.dropFirst()) : String(raw)
|
|
lines.append(line)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
/// Collapses the first blank-line-delimited paragraph of `lines` into one line
|
|
/// suitable for a `- Parameter` description.
|
|
///
|
|
/// Returns `nil` when `lines` is empty, when the first paragraph is blank, or
|
|
/// when it contains a ``` fence (which cannot survive being joined onto one line).
|
|
func firstParagraph(of lines: [String]) -> String? {
|
|
guard !lines.isEmpty else { return nil }
|
|
var paragraph: [String] = []
|
|
for line in lines {
|
|
if line.trimmingCharacters(in: .whitespaces).isEmpty { break }
|
|
// Fenced code blocks can't be collapsed to a single line.
|
|
if line.hasPrefix("```") { return nil }
|
|
paragraph.append(line.trimmingCharacters(in: .whitespaces))
|
|
}
|
|
let joined = paragraph.joined(separator: " ")
|
|
return joined.isEmpty ? nil : joined
|
|
}
|
|
|
|
/// Builds a member-name → doc-lines index from all `public var` and `public func`
|
|
/// declarations in `members`.
|
|
func extractMemberDocs(from members: MemberBlockItemListSyntax) -> [String: [String]] {
|
|
var result: [String: [String]] = [:]
|
|
for member in members {
|
|
let name: String?
|
|
let trivia: Trivia
|
|
if let varDecl = member.decl.as(VariableDeclSyntax.self),
|
|
varDecl.modifiers.contains(where: { $0.name.text == "public" }),
|
|
let binding = varDecl.bindings.first {
|
|
name = binding.pattern.description.trimmingCharacters(in: .whitespaces)
|
|
trivia = varDecl.leadingTrivia
|
|
} else if let funcDecl = member.decl.as(FunctionDeclSyntax.self),
|
|
funcDecl.modifiers.contains(where: { $0.name.text == "public" }) {
|
|
name = funcDecl.name.text
|
|
trivia = funcDecl.leadingTrivia
|
|
} else {
|
|
continue
|
|
}
|
|
guard let n = name, result[n] == nil else { continue }
|
|
let dl = docLines(from: trivia)
|
|
if !dl.isEmpty { result[n] = dl }
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: - Init extraction
|
|
|
|
func extractInits(from members: MemberBlockItemListSyntax) -> [InitModel] {
|
|
var result: [InitModel] = []
|
|
for member in members {
|
|
guard let initDecl = member.decl.as(InitializerDeclSyntax.self) else { continue }
|
|
|
|
// Must be convenience
|
|
guard initDecl.modifiers.contains(where: { $0.name.text == "convenience" }) else { continue }
|
|
|
|
// Exclude SPI inits
|
|
if initDecl.attributes.contains(where: { attr in
|
|
attr.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text == "_spi"
|
|
}) { continue }
|
|
|
|
// Exclude raw-pointer inits (single param labelled takingOwnership/retaining)
|
|
let params = initDecl.signature.parameterClause.parameters
|
|
if params.count == 1,
|
|
let firstName = params.first?.firstName,
|
|
firstName.text == "takingOwnership" || firstName.text == "retaining"
|
|
{
|
|
continue
|
|
}
|
|
|
|
// Skip no-arg inits — handled separately by the skeleton
|
|
guard params.count > 0 else { continue }
|
|
|
|
let parsedParams: [Param] = params.compactMap { p in
|
|
let type = p.type.description.trimmingCharacters(in: .whitespaces)
|
|
let label = p.firstName.text
|
|
return Param(label: label, type: type)
|
|
}
|
|
|
|
if !parsedParams.isEmpty {
|
|
result.append(InitModel(params: parsedParams, doc: docLines(from: initDecl.leadingTrivia)))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: - Property extraction
|
|
|
|
func extractProperties(from members: MemberBlockItemListSyntax) -> [PropertyModel] {
|
|
// Build setter method index for fallback when the computed property
|
|
// sets through `g_object_set_property` instead of calling the named setter.
|
|
var setterMethods: [String: (label: String, type: String)] = [:]
|
|
for member in members {
|
|
guard let funcDecl = member.decl.as(FunctionDeclSyntax.self) else { continue }
|
|
guard funcDecl.modifiers.contains(where: { $0.name.text == "public" }) else { continue }
|
|
let fname = funcDecl.name.text
|
|
guard let _ = fname.firstMatch(of: /^set[A-Z]/) else { continue }
|
|
guard let firstParam = funcDecl.signature.parameterClause.parameters.first else { continue }
|
|
let paramType = firstParam.type.description.trimmingCharacters(in: .whitespaces)
|
|
setterMethods[fname] = (firstParam.firstName.text, paramType)
|
|
}
|
|
|
|
// Build getter method index for reading the widget's current value back
|
|
// through a named getter rather than the computed `var` (avoids NULL-string
|
|
// crashes in non-optional String properties).
|
|
var getterMethods: [String: String] = [:] // method name -> return type
|
|
for member in members {
|
|
guard let funcDecl = member.decl.as(FunctionDeclSyntax.self) else { continue }
|
|
guard funcDecl.modifiers.contains(where: { $0.name.text == "public" }) else { continue }
|
|
let fname = funcDecl.name.text
|
|
guard let _ = fname.firstMatch(of: /^get[A-Z]/) else { continue }
|
|
guard funcDecl.signature.parameterClause.parameters.isEmpty else { continue }
|
|
guard let returnType = funcDecl.signature.returnClause?.type else { continue }
|
|
getterMethods[fname] = returnType.description.trimmingCharacters(in: .whitespaces)
|
|
}
|
|
|
|
var result: [PropertyModel] = []
|
|
for member in members {
|
|
guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue }
|
|
|
|
// Must be public
|
|
guard varDecl.modifiers.contains(where: { $0.name.text == "public" }) else { continue }
|
|
|
|
// Must have exactly one binding
|
|
let bindings = varDecl.bindings
|
|
guard bindings.count == 1, let binding = bindings.first else { continue }
|
|
|
|
// Must have a type annotation
|
|
guard let typeAnnotation = binding.typeAnnotation else { continue }
|
|
let typeStr = typeAnnotation.type.description.trimmingCharacters(in: .whitespaces)
|
|
|
|
// Must have accessor block with a set
|
|
guard let accessorBlock = binding.accessorBlock else { continue }
|
|
guard case .accessors(let accessorList) = accessorBlock.accessors else { continue }
|
|
|
|
guard let setAccessor = accessorList.first(where: { $0.accessorSpecifier.text == "set" }) else {
|
|
continue
|
|
}
|
|
|
|
let name = binding.pattern.description.trimmingCharacters(in: .whitespaces)
|
|
|
|
// Extract setter info from set accessor body, or fall back to sibling setter method.
|
|
let setterInfo: SetterInfo
|
|
if let extracted = extractSetter(from: setAccessor) {
|
|
setterInfo = extracted
|
|
} else {
|
|
let candidate = "set" + name.prefix(1).uppercased() + name.dropFirst()
|
|
guard let sibling = setterMethods[candidate], sibling.type == typeStr else { continue }
|
|
setterInfo = SetterInfo(name: candidate, label: sibling.label)
|
|
}
|
|
|
|
// Find a compatible zero-argument getter method for reading back the
|
|
// widget's current value.
|
|
let getterName: String?
|
|
let getterIsOptional: Bool
|
|
let getCandidate = "get" + name.prefix(1).uppercased() + name.dropFirst()
|
|
if let getReturnType = getterMethods[getCandidate],
|
|
getReturnType == typeStr || getReturnType == typeStr + "?" {
|
|
getterName = getCandidate
|
|
getterIsOptional = getReturnType == typeStr + "?"
|
|
} else {
|
|
getterName = nil
|
|
getterIsOptional = false
|
|
}
|
|
|
|
result.append(PropertyModel(
|
|
name: name,
|
|
type: typeStr,
|
|
setterName: setterInfo.name,
|
|
setterLabel: setterInfo.label,
|
|
getterName: getterName,
|
|
getterIsOptional: getterIsOptional
|
|
))
|
|
}
|
|
return result
|
|
}
|
|
|
|
struct SetterInfo {
|
|
let name: String
|
|
let label: String
|
|
}
|
|
|
|
func extractSetter(from accessor: AccessorDeclSyntax) -> SetterInfo? {
|
|
guard let body = accessor.body else { return nil }
|
|
let statements = body.statements
|
|
|
|
for stmt in statements {
|
|
guard let funcCall = findFunctionCall(in: stmt.item) else { continue }
|
|
let baseName: String
|
|
if let memberAccess = funcCall.calledExpression.as(MemberAccessExprSyntax.self) {
|
|
baseName = memberAccess.declName.baseName.text
|
|
} else if let declRef = funcCall.calledExpression.as(DeclReferenceExprSyntax.self) {
|
|
baseName = declRef.baseName.text
|
|
} else {
|
|
continue
|
|
}
|
|
|
|
// Must be a setter-like method (starts with "set")
|
|
guard baseName.hasPrefix("set") else { continue }
|
|
|
|
// Get the first argument label
|
|
let args = funcCall.arguments
|
|
guard let firstArg = args.first else { continue }
|
|
let label = firstArg.label?.text ?? "_"
|
|
|
|
return SetterInfo(name: baseName, label: label)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Recursively finds the first FunctionCallExprSyntax in a syntax node.
|
|
func findFunctionCall(in node: some SyntaxProtocol) -> FunctionCallExprSyntax? {
|
|
if let fc = node.as(FunctionCallExprSyntax.self) {
|
|
return fc
|
|
}
|
|
for child in node.children(viewMode: .all) {
|
|
if let found = findFunctionCall(in: child) {
|
|
return found
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Child adder extraction
|
|
|
|
/// Finds public methods that accept exactly one widget child.
|
|
func extractChildAdders(from members: MemberBlockItemListSyntax) -> [ChildAdder] {
|
|
let denied = ["addMnemonicLabel"]
|
|
var result: [ChildAdder] = []
|
|
for member in members {
|
|
guard let funcDecl = member.decl.as(FunctionDeclSyntax.self),
|
|
funcDecl.modifiers.contains(where: { $0.name.text == "public" }) else { continue }
|
|
let name = funcDecl.name.text
|
|
let suffix: String
|
|
if name.hasPrefix("append") {
|
|
suffix = String(name.dropFirst(6))
|
|
} else if name.hasPrefix("add") {
|
|
suffix = String(name.dropFirst(3))
|
|
} else {
|
|
continue
|
|
}
|
|
guard !denied.contains(name),
|
|
suffix.isEmpty || (suffix.first?.isUppercase ?? false) else { continue }
|
|
let params = funcDecl.signature.parameterClause.parameters
|
|
guard params.count == 1, let param = params.first else { continue }
|
|
var type = param.type.description.trimmingCharacters(in: .whitespaces)
|
|
while type.hasSuffix("?") || type.hasSuffix("!") { type.removeLast() }
|
|
guard type == "Widget" || type == "Gtk.Widget" || type == "Adw.Widget" else { continue }
|
|
result.append(ChildAdder(
|
|
methodName: name,
|
|
label: param.firstName.text,
|
|
returnsValue: funcDecl.signature.returnClause != nil
|
|
))
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: - Signal extraction
|
|
|
|
func extractSignals(from members: MemberBlockItemListSyntax) -> [SignalModel] {
|
|
var result: [SignalModel] = []
|
|
for member in members {
|
|
guard let funcDecl = member.decl.as(FunctionDeclSyntax.self) else { continue }
|
|
|
|
let funcName = funcDecl.name.text
|
|
guard funcName.hasPrefix("connect") else { continue }
|
|
|
|
// Must return SignalHandle
|
|
guard let returnType = funcDecl.signature.returnClause?.type,
|
|
returnType.description.trimmingCharacters(in: .whitespaces) == "SignalHandle"
|
|
else { continue }
|
|
|
|
// Extract base name (strip "connect" prefix)
|
|
let baseName = String(funcName.dropFirst("connect".count))
|
|
|
|
// Extract the GTK signal string from the function body.
|
|
var signalName = ""
|
|
if let body = funcDecl.body {
|
|
for stmt in body.statements {
|
|
guard let varDecl = stmt.item.as(VariableDeclSyntax.self),
|
|
let binding = varDecl.bindings.first,
|
|
binding.pattern.description.trimmingCharacters(in: .whitespaces) == "signalName",
|
|
let literal = binding.initializer?.value.as(StringLiteralExprSyntax.self)
|
|
else { continue }
|
|
signalName = literal.segments.lazy.compactMap {
|
|
$0.as(StringSegmentSyntax.self)?.content.text
|
|
}.joined()
|
|
break
|
|
}
|
|
}
|
|
if signalName.isEmpty {
|
|
// Fallback: kebab-case the base name.
|
|
var fb = ""
|
|
for (i, ch) in baseName.enumerated() {
|
|
if i == 0 { fb.append(ch.lowercased()); continue }
|
|
if ch.isUppercase { fb.append("-"); fb.append(ch.lowercased()) }
|
|
else { fb.append(ch) }
|
|
}
|
|
signalName = fb
|
|
}
|
|
// Extract signal info from the handler parameter
|
|
let params = funcDecl.signature.parameterClause.parameters
|
|
guard params.count == 1,
|
|
let firstParam = params.first,
|
|
let funcType = unwrapFunctionType(firstParam.type)
|
|
else { continue }
|
|
|
|
// Extract parameter types (drop the first — widget self)
|
|
let paramTypes = funcType.parameters.map { $0.type.description.trimmingCharacters(in: .whitespaces) }
|
|
let argTypes = Array(paramTypes.dropFirst())
|
|
|
|
// Extract return type
|
|
let retType = funcType.returnClause.type.description.trimmingCharacters(in: .whitespaces)
|
|
|
|
result.append(SignalModel(
|
|
baseName: baseName,
|
|
signalName: signalName,
|
|
argTypes: argTypes,
|
|
returnType: retType
|
|
))
|
|
}
|
|
return result
|
|
}
|
|
|
|
|
|
/// Unwraps `AttributedTypeSyntax` (e.g. `@escaping (Button) -> Void`) to get the
|
|
/// inner `FunctionTypeSyntax`.
|
|
func unwrapFunctionType(_ type: TypeSyntax) -> FunctionTypeSyntax? {
|
|
if let ft = type.as(FunctionTypeSyntax.self) {
|
|
return ft
|
|
}
|
|
if let attr = type.as(AttributedTypeSyntax.self) {
|
|
return attr.baseType.as(FunctionTypeSyntax.self)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Extracts the no-arg `convenience init()` if it exists, or returns `nil`.
|
|
func extractNoArgInit(in members: MemberBlockItemListSyntax) -> InitModel? {
|
|
for member in members {
|
|
guard let initDecl = member.decl.as(InitializerDeclSyntax.self) else { continue }
|
|
guard initDecl.modifiers.contains(where: { $0.name.text == "convenience" }) else { continue }
|
|
let params = initDecl.signature.parameterClause.parameters
|
|
if params.isEmpty {
|
|
// Exclude SPI inits
|
|
if initDecl.attributes.contains(where: { attr in
|
|
attr.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text == "_spi"
|
|
}) { continue }
|
|
return InitModel(params: [], doc: docLines(from: initDecl.leadingTrivia))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
// MARK: - Errors
|
|
|
|
enum ParseError: Error, CustomStringConvertible {
|
|
case noClassFound(String)
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .noClassFound(let path):
|
|
return "No public class found in \(path)"
|
|
}
|
|
}
|
|
}
|