diff --git a/packages/swift/.gitignore b/packages/swift/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/packages/swift/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/packages/swift/Package.swift b/packages/swift/Package.swift new file mode 100644 index 000000000..53cd63c64 --- /dev/null +++ b/packages/swift/Package.swift @@ -0,0 +1,39 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "OpenUI", + platforms: [ + .macOS(.v13), + .iOS(.v16) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "OpenUILang", + targets: ["OpenUILang"]), + .library( + name: "OpenUISwiftUI", + targets: ["OpenUISwiftUI"]), + .executable( + name: "SwiftUIChat", + targets: ["SwiftUIChat"]) + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "OpenUILang"), + .target( + name: "OpenUISwiftUI", + dependencies: ["OpenUILang"]), + .executableTarget( + name: "SwiftUIChat", + dependencies: ["OpenUILang", "OpenUISwiftUI"]), + .testTarget( + name: "OpenUILangTests", + dependencies: ["OpenUILang"]) + ] +) diff --git a/packages/swift/Sources/OpenUILang/AST.swift b/packages/swift/Sources/OpenUILang/AST.swift new file mode 100644 index 000000000..7e8691b59 --- /dev/null +++ b/packages/swift/Sources/OpenUILang/AST.swift @@ -0,0 +1,40 @@ +public indirect enum ASTNode { + case comp(name: String, args: [ASTNode], mappedProps: [String: ASTNode]?) + case str(String) + case num(Double) + case bool(Bool) + case null + case arr([ASTNode]) + case obj([(String, ASTNode)]) + case ref(String) + case ph(String) + case stateRef(String) + case runtimeRef(name: String, refType: String) + case binOp(op: String, left: ASTNode, right: ASTNode) + case unaryOp(op: String, operand: ASTNode) + case ternary(cond: ASTNode, then: ASTNode, `else`: ASTNode) + case member(obj: ASTNode, field: String) + case index(obj: ASTNode, index: ASTNode) + case assign(target: String, value: ASTNode) + + public var isRuntimeExpr: Bool { + switch self { + case .stateRef, .runtimeRef, .binOp, .unaryOp, .ternary, .member, .index, .assign: + return true + default: + return false + } + } +} + +public struct CallNode { + public let callee: String + public let args: [ASTNode] +} + +public enum Statement { + case value(id: String, expr: ASTNode) + case state(id: String, initExpr: ASTNode) + case query(id: String, call: CallNode, expr: ASTNode, deps: [String]?) + case mutation(id: String, call: CallNode, expr: ASTNode) +} diff --git a/packages/swift/Sources/OpenUILang/Library.swift b/packages/swift/Sources/OpenUILang/Library.swift new file mode 100644 index 000000000..077ff1104 --- /dev/null +++ b/packages/swift/Sources/OpenUILang/Library.swift @@ -0,0 +1,55 @@ +import Foundation + +public struct ComponentDef { + public let name: String + public let description: String + public let signature: String + public let params: [String] + + public init(name: String, description: String, signature: String) { + self.name = name + self.description = description + self.signature = signature + + // Extract parameter names from signature, e.g., "Button(label: String, action: String)" -> ["label", "action"] + // "VStack(children: [Any])" -> ["children"] + var extractedParams: [String] = [] + if let startRange = signature.range(of: "("), let endRange = signature.range(of: ")", options: .backwards, range: startRange.upperBound.. Any +} + +public class ComponentLibrary { + public private(set) var components: [String: ComponentDef] = [:] + public var root: String? + + public init() {} + + public func register(component: ComponentDef) { + components[component.name] = component + } + + public func setRoot(_ root: String) { + self.root = root + } +} diff --git a/packages/swift/Sources/OpenUILang/Parser.swift b/packages/swift/Sources/OpenUILang/Parser.swift new file mode 100644 index 000000000..28b80b79a --- /dev/null +++ b/packages/swift/Sources/OpenUILang/Parser.swift @@ -0,0 +1,294 @@ +import Foundation + +public class Parser { + private var input: String + private var index: String.Index + public var library: ComponentLibrary? + + public init(library: ComponentLibrary? = nil) { + self.input = "" + self.index = "".startIndex + self.library = library + } + + public func parse(_ text: String) -> ParseResult { + self.input = text + self.index = text.startIndex + + var statements: [Statement] = [] + var context: [String: ASTNode] = [:] + + while index < input.endIndex { + skipWhitespace() + guard index < input.endIndex else { break } + + if input[index] == "$" { + advance() + if let id = parseIdentifier() { + skipWhitespace() + if match("=") { + skipWhitespace() + if let expr = parseExpression() { + statements.append(.state(id: id, initExpr: expr)) + context[id] = expr + } + } + } + } else if let id = parseIdentifier() { + skipWhitespace() + if match("=") { + skipWhitespace() + if let expr = parseExpression() { + if case let .comp(name, _, _) = expr, name == "Query" { + statements.append(.query(id: id, call: CallNode(callee: name, args: []), expr: expr, deps: nil)) + } else if case let .comp(name, _, _) = expr, name == "Mutation" { + statements.append(.mutation(id: id, call: CallNode(callee: name, args: []), expr: expr)) + } else { + statements.append(.value(id: id, expr: expr)) + context[id] = expr + } + } + } + } else { + // Skip unparseable tokens + index = input.index(after: index) + } + } + + var rootNode: ElementNode? = nil + let entryId = statements.first(where: { + if case let .value(id, _) = $0 { return id == "root" } + return false + }) != nil ? "root" : statements.first(where: { + if case .value = $0 { return true } + return false + }).flatMap { + if case let .value(id, _) = $0 { return id } + return nil + } + + if let entryId = entryId, let rootExpr = context[entryId] { + rootNode = materialize(rootExpr, context: context, statementId: entryId) + } + + let meta = ParseResultMeta(incomplete: false, unresolved: [], orphaned: [], statementCount: statements.count, errors: []) + return ParseResult(root: rootNode, meta: meta, stateDeclarations: [:], queryStatements: [], mutationStatements: []) + } + + private func parseExpression() -> ASTNode? { + guard let primary = parsePrimaryExpression() else { return nil } + + skipWhitespace() + if match("?") { + skipWhitespace() + guard let thenExpr = parseExpression() else { return primary } + skipWhitespace() + if match(":") { + skipWhitespace() + guard let elseExpr = parseExpression() else { return primary } + return .ternary(cond: primary, then: thenExpr, else: elseExpr) + } + } + + return primary + } + + private func parsePrimaryExpression() -> ASTNode? { + skipWhitespace() + if index >= input.endIndex { return nil } + + let c = input[index] + if c == "\"" { + return .str(parseString()) + } else if c == "[" { + return .arr(parseArray()) + } else if c == "$" { + advance() + if let ident = parseIdentifier() { + return .stateRef(ident) + } + return nil + } else if c.isNumber { + return .num(parseNumber()) + } else if input[index...].hasPrefix("true") { + advance(by: 4); return .bool(true) + } else if input[index...].hasPrefix("false") { + advance(by: 5); return .bool(false) + } else if let ident = parseIdentifier() { + skipWhitespace() + if match("(") { + let args = parseArguments() + return .comp(name: ident, args: args.0, mappedProps: args.1) + } else { + return .ref(ident) + } + } + return nil + } + + private func parseString() -> String { + advance() // skip " + var result = "" + while index < input.endIndex && input[index] != "\"" { + result.append(input[index]) + advance() + } + if index < input.endIndex { advance() } // skip " + return result + } + + private func parseNumber() -> Double { + var result = "" + while index < input.endIndex && (input[index].isNumber || input[index] == ".") { + result.append(input[index]) + advance() + } + return Double(result) ?? 0 + } + + private func parseArray() -> [ASTNode] { + advance() // skip [ + var elements: [ASTNode] = [] + while index < input.endIndex { + skipWhitespace() + if match("]") { break } + if let expr = parseExpression() { + elements.append(expr) + } + skipWhitespace() + _ = match(",") + } + return elements + } + + private func parseArguments() -> ([ASTNode], [String: ASTNode]) { + var args: [ASTNode] = [] + var props: [String: ASTNode] = [:] + + while index < input.endIndex { + skipWhitespace() + if match(")") { break } + + let startIdx = index + if let ident = parseIdentifier() { + skipWhitespace() + if match(":") { + skipWhitespace() + if let expr = parseExpression() { + props[ident] = expr + } + } else { + index = startIdx + if let expr = parseExpression() { + args.append(expr) + } + } + } else { + if let expr = parseExpression() { + args.append(expr) + } + } + + skipWhitespace() + _ = match(",") + } + + return (args, props) + } + + private func parseIdentifier() -> String? { + guard index < input.endIndex, input[index].isLetter || input[index] == "_" else { return nil } + var result = "" + while index < input.endIndex && (input[index].isLetter || input[index].isNumber || input[index] == "_") { + result.append(input[index]) + advance() + } + return result + } + + private func skipWhitespace() { + while index < input.endIndex && input[index].isWhitespace { + advance() + } + } + + private func match(_ str: String) -> Bool { + if input[index...].hasPrefix(str) { + advance(by: str.count) + return true + } + return false + } + + private func advance(by count: Int = 1) { + index = input.index(index, offsetBy: count, limitedBy: input.endIndex) ?? input.endIndex + } + + private func materialize(_ node: ASTNode, context: [String: ASTNode], statementId: String? = nil) -> ElementNode? { + switch node { + case let .ref(ident): + if let refNode = context[ident] { + return materialize(refNode, context: context, statementId: ident) + } + return nil + case let .comp(name, args, mappedProps): + var propsMap: [String: Any] = [:] + var finalMappedProps = mappedProps ?? [:] + + if let library = self.library, let compDef = library.components[name] { + for (i, arg) in args.enumerated() { + if i < compDef.params.count { + let paramName = compDef.params[i] + if finalMappedProps[paramName] == nil { + finalMappedProps[paramName] = arg + } + } + } + } else { + // If no library is provided, fallback to "children" for the first arg if it's an array + if args.count > 0 && finalMappedProps["children"] == nil { + finalMappedProps["children"] = args[0] + } + } + + for (k, v) in finalMappedProps { + if let val = materializeValue(v, context: context) { + propsMap[k] = val + } + } + return ElementNode(statementId: statementId, typeName: name, props: propsMap, partial: false) + default: + return nil + } + } + + private func materializeValue(_ node: ASTNode, context: [String: ASTNode]) -> Any? { + switch node { + case let .str(s): return s + case let .num(n): return n + case let .bool(b): return b + case let .arr(arr): + return arr.compactMap { materializeValue($0, context: context) } + case .comp: + if let el = materialize(node, context: context) { + // For OpenUI Lang, children components are usually wrapped in arrays, but if not we might need it. + // Just return the element node itself. + return el + } + return nil + case let .ref(ident): + if let resolved = context[ident] { + if case .comp = resolved { + return materialize(resolved, context: context, statementId: ident) + } + return materializeValue(resolved, context: context) + } + return nil + case let .stateRef(ident): + return "$\(ident)" // Simplified placeholder for dynamic state + case .ternary: + return "$ternary" // Simplified placeholder + default: return nil + } + } +} diff --git a/packages/swift/Sources/OpenUILang/PromptGenerator.swift b/packages/swift/Sources/OpenUILang/PromptGenerator.swift new file mode 100644 index 000000000..f68b466ac --- /dev/null +++ b/packages/swift/Sources/OpenUILang/PromptGenerator.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct PromptOptions { + public var preamble: String? + public var additionalRules: [String]? + public var examples: [String]? + public var toolExamples: [String]? + public var tools: [String]? + + public init(preamble: String? = nil, additionalRules: [String]? = nil, examples: [String]? = nil, toolExamples: [String]? = nil, tools: [String]? = nil) { + self.preamble = preamble + self.additionalRules = additionalRules + self.examples = examples + self.toolExamples = toolExamples + self.tools = tools + } +} + +public class PromptGenerator { + public static func generatePrompt(library: ComponentLibrary, options: PromptOptions? = nil) -> String { + var prompt = "" + + if let preamble = options?.preamble { + prompt += preamble + "\n\n" + } else { + prompt += "You are a UI generation assistant. Respond ONLY with valid OpenUI Lang code.\n\n" + } + + prompt += "### Components\n\n" + for (_, comp) in library.components.sorted(by: { $0.key < $1.key }) { + prompt += "- \(comp.signature): \(comp.description)\n" + } + + if let root = library.root { + prompt += "\nRoot Component: \(root)\n" + } + + if let rules = options?.additionalRules, !rules.isEmpty { + prompt += "\n### Rules\n" + for rule in rules { + prompt += "- \(rule)\n" + } + } + + return prompt.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/packages/swift/Sources/OpenUILang/Types.swift b/packages/swift/Sources/OpenUILang/Types.swift new file mode 100644 index 000000000..ed9b3463f --- /dev/null +++ b/packages/swift/Sources/OpenUILang/Types.swift @@ -0,0 +1,83 @@ +public enum ValidationErrorCode: String { + case missingRequired = "missing-required" + case nullRequired = "null-required" + case unknownComponent = "unknown-component" + case inlineReserved = "inline-reserved" + case excessArgs = "excess-args" +} + +public struct ValidationError: Equatable { + public let code: ValidationErrorCode + public let component: String + public let path: String + public let message: String + public let statementId: String? +} + +public struct ElementNode: Equatable { + public let type = "element" + public let statementId: String? + public let typeName: String + // In Swift we can use Any, but Equatable with Any is tricky. + // For now we'll represent props as [String: AnyHashable] or a custom enum value if needed. + // We can also use a generic AnyCodable if we bring one in. Let's use `[String: Any]` and skip Equatable for now, or just implement it. + public let props: [String: Any] + public let partial: Bool + public let hasDynamicProps: Bool? + + public init(statementId: String?, typeName: String, props: [String: Any], partial: Bool, hasDynamicProps: Bool? = nil) { + self.statementId = statementId + self.typeName = typeName + self.props = props + self.partial = partial + self.hasDynamicProps = hasDynamicProps + } + + public static func == (lhs: ElementNode, rhs: ElementNode) -> Bool { + lhs.statementId == rhs.statementId && + lhs.typeName == rhs.typeName && + lhs.partial == rhs.partial && + lhs.hasDynamicProps == rhs.hasDynamicProps + // skipping deep prop equality for now + } +} + +public struct ParseResultMeta { + public let incomplete: Bool + public let unresolved: [String] + public let orphaned: [String] + public let statementCount: Int + public let errors: [ValidationError] +} + +public struct QueryStatementInfo { + public let statementId: String + public let toolAST: ASTNode? + public let argsAST: ASTNode? + public let defaultsAST: ASTNode? + public let refreshAST: ASTNode? + public let deps: [String]? + public let complete: Bool +} + +public struct MutationStatementInfo { + public let statementId: String + public let toolAST: ASTNode? + public let argsAST: ASTNode? +} + +public struct ParseResult { + public let root: ElementNode? + public let meta: ParseResultMeta + public let stateDeclarations: [String: Any] + public let queryStatements: [QueryStatementInfo] + public let mutationStatements: [MutationStatementInfo] +} + +public struct ParamDef { + public let name: String + public let required: Bool + public let defaultValue: Any? +} + +public typealias ParamMap = [String: [ParamDef]] diff --git a/packages/swift/Sources/OpenUISwiftUI/OpenUIRenderer.swift b/packages/swift/Sources/OpenUISwiftUI/OpenUIRenderer.swift new file mode 100644 index 000000000..54bf5c257 --- /dev/null +++ b/packages/swift/Sources/OpenUISwiftUI/OpenUIRenderer.swift @@ -0,0 +1,62 @@ +import SwiftUI +#if canImport(OpenUILang) +import OpenUILang +#endif + +public struct OpenUIRenderer: View { + let node: ElementNode? + let library: ComponentLibrary + let actionHandler: ((String, [String: Any]) -> Void)? + + public init(node: ElementNode?, library: ComponentLibrary, actionHandler: ((String, [String: Any]) -> Void)? = nil) { + self.node = node + self.library = library + self.actionHandler = actionHandler + } + + public var body: some View { + if let node = node { + renderNode(node) + } else { + AnyView(Text("No UI to render") + .foregroundColor(.gray)) + } + } + + private func renderNode(_ node: ElementNode) -> AnyView { + switch node.typeName { + case "VStack": + return AnyView(VStack { + renderChildren(node.props["children"] as? [ElementNode]) + }) + case "HStack": + return AnyView(HStack { + renderChildren(node.props["children"] as? [ElementNode]) + }) + case "Text": + return AnyView(Text((node.props["text"] as? String) ?? "")) + case "Button": + return AnyView(Button(action: { + if let action = node.props["action"] as? String { + actionHandler?(action, node.props) + } else { + print("Button clicked: \(node.props["label"] as? String ?? "")") + } + }) { + Text((node.props["label"] as? String) ?? "Button") + }) + default: + return AnyView(Text("Unknown component: \(node.typeName)") + .foregroundColor(.red)) + } + } + + @ViewBuilder + private func renderChildren(_ children: [ElementNode]?) -> some View { + if let children = children { + ForEach(0..