portico/Sources/PorticoGen/Parser.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)"
}
}
}