311 lines
13 KiB
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()
|