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//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)" } } }