portico/Sources/PorticoGen/main.swift

311 lines
13 KiB
Swift

import Foundation
import SwiftParser
import SwiftSyntax
// PorticoGen code generator for Portico widget wrappers.
// Parses gtk-swift generated wrapper .swift files and emits Portico wrappers.
/// Widgets that must never be generated because a hand-written type already
/// is their wrapper. Keyed as `"Module.ClassName"`.
private let excluded: Set<String> = ["Adw.ApplicationWindow"]
/// One generated output file: an optional struct block plus every modifier
/// extension block that belongs to the same class name.
private struct OutputFile {
var isPorticoGtk = false
var structBlock: String?
/// `(sortKey, body)` sorted before emission so output is deterministic.
var modifierBlocks: [(key: String, body: String)] = []
}
func main() {
let args = CommandLine.arguments
guard args.count >= 3 else {
print("Usage: PorticoGen --portico-out <dir> --gtk-out <dir> --scan <dir> --reexports-out <file> --gtk-swift-root <dir> <input1.swift> [<input2.swift> ...]")
exit(1)
}
var porticoOutDir: String?
var gtkOutDir: String?
var scanDir: String?
var reexportsOutPath: String?
var gtkSwiftRoot: String?
var inputs: [String] = []
var i = 1
while i < args.count {
switch args[i] {
case "--portico-out":
guard i + 1 < args.count else { fatalError("--portico-out requires a directory argument") }
porticoOutDir = args[i + 1]; i += 2
case "--gtk-out":
guard i + 1 < args.count else { fatalError("--gtk-out requires a directory argument") }
gtkOutDir = args[i + 1]; i += 2
case "--scan":
guard i + 1 < args.count else { fatalError("--scan requires a directory argument") }
scanDir = args[i + 1]; i += 2
case "--reexports-out":
guard i + 1 < args.count else { fatalError("--reexports-out requires a file argument") }
reexportsOutPath = args[i + 1]; i += 2
case "--gtk-swift-root":
guard i + 1 < args.count else { fatalError("--gtk-swift-root requires a directory argument") }
gtkSwiftRoot = args[i + 1]; i += 2
case let arg where arg.hasPrefix("--"):
print("Error: unknown flag \(arg)"); exit(1)
default:
inputs.append(args[i]); i += 1
}
}
guard let porticoOutDir else { fatalError("--portico-out <dir> is required") }
guard let gtkOutDir else { fatalError("--gtk-out <dir> is required") }
guard let scanDir else { fatalError("--scan <dir> is required") }
guard let reexportsOutPath else { fatalError("--reexports-out <file> is required") }
guard let gtkSwiftRoot else { fatalError("--gtk-swift-root <dir> is required") }
guard !inputs.isEmpty else { fatalError("at least one input file is required") }
// Phase 1: parse all inputs
var allModels: [(path: String, model: WidgetModel)] = []
for input in inputs {
do {
let models = try parseWidgets(filePath: input)
for model in models { allModels.append((input, model)) }
} catch {
print("Warning: skipping \(input)\(error)")
}
}
// Build parent and model maps: "Module.ClassName" -> parsed model and parent class name
for (_, m) in allModels {
let key = "\(m.module).\(m.className)"
widgetParentMap[key] = m.parentClass
widgetModelMap[key] = m
}
// Phase 2: inheritance walk widget set
var widgetKeys = Set<String>()
func isWidget(module: String, className: String, visited: inout Set<String>) -> Bool {
let key = "\(module).\(className)"
if widgetKeys.contains(key) { return true }
if key == "Gtk.Widget" || key == "Adw.Widget" { return true }
guard visited.insert(key).inserted else { return false }
var parent = widgetParentMap[key].flatMap { $0 } ?? ""
// Strip trailing whitespace / protocol clauses (parent class is the first token)
if let spaceIdx = parent.firstIndex(where: { $0 == " " || $0 == "," }) {
parent = String(parent[..<spaceIdx])
}
if parent.isEmpty { return false }
// Resolve parent to a fully-qualified key.
let parentKey: String
if parent.contains(".") {
// Already module-qualified, e.g. "Gtk.Window". Use it directly.
parentKey = parent
} else if parent == className {
// Adw.Window : Gtk.Window case try other module first.
let other = (module == "Adw") ? "Gtk" : "Adw"
if widgetParentMap["\(other).\(parent)"] != nil { parentKey = "\(other).\(parent)" }
else { parentKey = "\(module).\(parent)" }
} else if widgetParentMap["\(module).\(parent)"] != nil { parentKey = "\(module).\(parent)" }
else if widgetParentMap["Gtk.\(parent)"] != nil { parentKey = "Gtk.\(parent)" }
else if widgetParentMap["Adw.\(parent)"] != nil { parentKey = "Adw.\(parent)" }
else { return false }
guard widgetParentMap[parentKey] != nil else { return false }
var v = visited
return isWidget(
module: String(parentKey.split(separator: ".")[0]),
className: String(parentKey.split(separator: ".")[1]),
visited: &v
)
}
// Resolve all widgets.
for (_, m) in allModels {
var visited = Set<String>()
if isWidget(module: m.module, className: m.className, visited: &visited) {
widgetKeys.insert("\(m.module).\(m.className)")
}
}
// Build widgets list: (path, module, className, WidgetModel)
let widgets: [(path: String, module: String, className: String, model: WidgetModel)] =
allModels.compactMap {
let key = "\($0.model.module).\($0.model.className)"
guard widgetKeys.contains(key) else { return nil }
return ($0.path, $0.model.module, $0.model.className, $0.model)
}
// Publish the widget classes so `bindingModifier` can emit generic `Binding`
// overloads for subclass-typed properties. Interfaces are excluded: a protocol
// constraint does not upcast to a setter's widget parameter type.
widgetClassKeys = Set(
widgets.lazy
.filter { $0.model.kind == .widgetClass }
.map { "\($0.module).\($0.className)" }
)
print("Widgets: \(widgets.count) of \(allModels.count) parsed classes")
// Compute the set of names that exist in both Adw and Gtk widget sets.
var adwNames = Set<String>(), gtkNames = Set<String>()
for (_, m, c, _) in widgets {
if m == "Adw" { adwNames.insert(c) } else { gtkNames.insert(c) }
}
let bothFrameworks = adwNames.intersection(gtkNames)
// Phase 3: scan hand-written names
var handWrittenNames = Set<String>()
if let enumerator = FileManager.default.enumerator(
at: URL(fileURLWithPath: scanDir),
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) {
for case let url as URL in enumerator {
// Skip the Generated/ directory itself.
if url.path.contains("/Generated/") { continue }
guard let source = try? String(contentsOf: url, encoding: .utf8) else { continue }
handWrittenNames.formUnion(collectTopLevelNames(source))
}
}
// Phase 4: route and generate
let porticoURL = URL(fileURLWithPath: porticoOutDir)
let gtkURL = URL(fileURLWithPath: gtkOutDir)
try? FileManager.default.createDirectory(at: porticoURL, withIntermediateDirectories: true)
try? FileManager.default.createDirectory(at: gtkURL, withIntermediateDirectories: true)
var files: [String: OutputFile] = [:]
var generatedStructNames = Set<String>()
for (_, module, className, model) in widgets {
let fullKey = "\(module).\(className)"
guard !excluded.contains(fullKey) else { continue }
let skipStruct = model.kind == .interface
|| (model.noArgInit == nil && model.inits.isEmpty)
let structName: String
let outDir: String
let isPorticoGtk: Bool
if bothFrameworks.contains(className), module == "Gtk" {
structName = className
outDir = gtkOutDir
isPorticoGtk = true
} else if handWrittenNames.contains(className) {
structName = "\(module)\(className)"
outDir = porticoOutDir
isPorticoGtk = false
} else {
structName = className
outDir = porticoOutDir
isPorticoGtk = false
}
generatedStructNames.insert(structName)
// Struct block -> the file its routing chose.
if !skipStruct {
let path = URL(fileURLWithPath: outDir)
.appendingPathComponent("\(structName).swift").path
var f = files[path] ?? OutputFile()
f.isPorticoGtk = isPorticoGtk
f.structBlock = generateStruct(widget: model, structName: structName)
files[path] = f
}
// Modifier block -> always the Portico module. Merges into the struct's
// own file when that struct is in Portico, otherwise a bare <Class>.swift.
let modifierBody = generateModifierExtension(widget: model)
if !modifierBody.isEmpty {
let name = (!skipStruct && !isPorticoGtk) ? structName
: handWrittenNames.contains(className) ? "\(module)\(className)"
: className
generatedStructNames.insert(name)
let path = porticoURL.appendingPathComponent("\(name).swift").path
var f = files[path] ?? OutputFile()
f.modifierBlocks.append((fullKey, modifierBody))
files[path] = f
}
// Gtk.Widget additionally owns the erasing `extension View` fallback.
if module == "Gtk", className == "Widget" {
let path = porticoURL.appendingPathComponent("Widget.swift").path
var f = files[path] ?? OutputFile()
f.modifierBlocks.append(("zz.View", generateViewExtension(widget: model)))
files[path] = f
}
}
for (path, file) in files.sorted(by: { $0.key < $1.key }) {
var out = generateHeader(isPorticoGtk: file.isPorticoGtk)
if let structBlock = file.structBlock { out += structBlock }
for block in file.modifierBlocks.sorted(by: { $0.key < $1.key }) {
if !out.hasSuffix("\n\n") { out += "\n" }
out += block.body
}
do {
try out.write(toFile: path, atomically: true, encoding: .utf8)
print("Generated \(path)")
} catch {
print("Error writing \(path): \(error)")
exit(1)
}
}
generateReexports(
gtkSwiftRoot: gtkSwiftRoot,
reservedNames: handWrittenNames.union(generatedStructNames),
reexportsPath: reexportsOutPath
)
}
// MARK: - Hand-written name scanner
/// Collects every top-level `public` type name in a source file.
private func collectTopLevelNames(_ source: String) -> Set<String> {
let tree = Parser.parse(source: source)
var names = Set<String>()
for stmt in tree.statements {
let item = stmt.item
if let classDecl = item.as(ClassDeclSyntax.self),
classDecl.modifiers.contains(where: { $0.name.text == "public" }) {
names.insert(classDecl.name.text)
} else if let structDecl = item.as(StructDeclSyntax.self),
structDecl.modifiers.contains(where: { $0.name.text == "public" }) {
names.insert(structDecl.name.text)
} else if let enumDecl = item.as(EnumDeclSyntax.self),
enumDecl.modifiers.contains(where: { $0.name.text == "public" }) {
names.insert(enumDecl.name.text)
} else if let protoDecl = item.as(ProtocolDeclSyntax.self),
protoDecl.modifiers.contains(where: { $0.name.text == "public" }) {
names.insert(protoDecl.name.text)
}
}
return names
}
/// Collects every top-level `public`/`open` type name in a source file:
/// `class`, `struct`, `enum`, and `protocol` declarations.
func collectDeclaredTypeNames(_ source: String) -> Set<String> {
let tree = Parser.parse(source: source)
var names = Set<String>()
func isPublicOrOpen(_ modifiers: DeclModifierListSyntax) -> Bool {
modifiers.contains { $0.name.text == "public" || $0.name.text == "open" }
}
for stmt in tree.statements {
let item = stmt.item
if let d = item.as(ClassDeclSyntax.self), isPublicOrOpen(d.modifiers) {
names.insert(d.name.text)
} else if let d = item.as(StructDeclSyntax.self), isPublicOrOpen(d.modifiers) {
names.insert(d.name.text)
} else if let d = item.as(EnumDeclSyntax.self), isPublicOrOpen(d.modifiers) {
names.insert(d.name.text)
} else if let d = item.as(ProtocolDeclSyntax.self), isPublicOrOpen(d.modifiers) {
names.insert(d.name.text)
}
}
return names
}
main()