diff --git a/FishyTransport/Package.swift b/FishyTransport/Package.swift index 79548aa..ab2f4de 100644 --- a/FishyTransport/Package.swift +++ b/FishyTransport/Package.swift @@ -40,7 +40,7 @@ let package = Package( "FishyActorTransportPlugin" ] ), - + // would be provided by transport library .executable( name: "FishyActorsGenerator", @@ -80,12 +80,12 @@ let package = Package( "FishyActorsGenerator" ] ), - + .executableTarget( name: "FishyActorsGenerator", dependencies: [ - .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "SwiftSyntax", package: "swift-syntax"), ] ), @@ -94,11 +94,18 @@ let package = Package( .testTarget( name: "FishyActorTransportTests", dependencies: [ - "FishyActorTransport" + "FishyActorTransport", ], swiftSettings: [ .unsafeFlags(experimentalFlags) ] ), + + .testTarget( + name: "FishyActorsGeneratorTests", + dependencies: [ + "FishyActorsGenerator", + ] + ), ] ) diff --git a/FishyTransport/Sources/FishyActorsGenerator/Analysis.swift b/FishyTransport/Sources/FishyActorsGenerator/Analysis.swift index 3dfb04a..188d4a8 100644 --- a/FishyTransport/Sources/FishyActorsGenerator/Analysis.swift +++ b/FishyTransport/Sources/FishyActorsGenerator/Analysis.swift @@ -28,7 +28,7 @@ final class Analysis: SyntaxVisitor { self.verbose = verbose } - func run() { + public func run() { let enumerator = FileManager.default.enumerator(atPath: sourceDirectory) while let path = enumerator?.nextObject() as? String { guard path.hasSuffix(".swift") else { @@ -112,7 +112,7 @@ final class Analysis: SyntaxVisitor { let resultTypeNaive: String if let t = node.signature.output?.returnType { - resultTypeNaive = "\(t)" + resultTypeNaive = "\(t.withoutTrivia())" } else { // pretty naive representation, prefer an enum resultTypeNaive = "Void" @@ -121,11 +121,11 @@ final class Analysis: SyntaxVisitor { // TODO: this is just a naive implementation, we'd carry all information here let fun = FuncDecl( access: .internal, - name: node.identifier.text.trimmingCharacters(in: .whitespaces), + name: node.identifier.withoutTrivia().text, params: node.signature.gatherParams(), throwing: isThrowing, async: isAsync, - result: resultTypeNaive.trimmingCharacters(in: .whitespaces) + result: resultTypeNaive ) actorDecl.funcs.append(fun) @@ -205,7 +205,7 @@ extension FunctionSignatureSyntax { // ==== ---------------------------------------------------------------------------------------------------------------- // MARK: Analysis decls -struct DistributedActorDecl { +public struct DistributedActorDecl { let access: AccessControl let name: String var funcs: [FuncDecl] diff --git a/FishyTransport/Sources/FishyActorsGenerator/SourceGen.swift b/FishyTransport/Sources/FishyActorsGenerator/SourceGen.swift index 188a297..78d97fe 100644 --- a/FishyTransport/Sources/FishyActorsGenerator/SourceGen.swift +++ b/FishyTransport/Sources/FishyActorsGenerator/SourceGen.swift @@ -20,39 +20,35 @@ final class SourceGen { // DO NOT MODIFY: This file will be re-generated automatically. // Source generated by FishyActorsGenerator (version x.y.z) import _Distributed - + import FishyActorTransport import ArgumentParser import Logging - + import func Foundation.sleep import struct Foundation.Data import class Foundation.JSONDecoder - + + """) - var targetDirectory: String var buckets: Int + private var currentBucket: Int = 0 // TODO: Remove me eventually - init(targetDirectory: String, buckets: Int) { - self.targetDirectory = targetDirectory + init(buckets: Int) { + if buckets > 1 { print("Warning: requested \(buckets) buckets, but bucketing is not implemented yet.") } self.buckets = buckets - - // TODO: Don't do this in init - // Just make sure all "buckets" exist - for i in (0.. URL { - let targetURL = targetFilePath(targetDirectory: targetDirectory, i: 1) // TODO: hardcoded for now, would use bucketing approach to avoid re-generating too many sources - - try! generateSources(for: decl, to: targetURL) - - // return into which output file the extensions were generated - return targetURL + func generate(decl: DistributedActorDecl) throws -> GeneratedSource { + // TODO: Implement a proper bucketing approach to avoid re-generating too many sources + // Currently each new decl goes into the next bucket without taking anything else into account. + // It will create more buckets than requested if given enough decls + if currentBucket >= buckets { + print("Warning: too many distributed actors declared, please increase requested buckets (currently \(buckets) buckets; this is a temporary limitation of FishyTransport).") + } + defer { currentBucket += 1 } + return try GeneratedSource(text: generateSources(for: decl), bucket: currentBucket) } //*************************************************************************************// @@ -62,13 +58,12 @@ final class SourceGen { //** See: https://github.com/apple/swift-syntax/tree/main/Sources/SwiftSyntaxBuilder **// //*************************************************************************************// - private func generateSources(for decl: DistributedActorDecl, to file: URL) throws { - var sourceText = "" + private func generateSources(for decl: DistributedActorDecl) throws -> String { + var sourceText = SourceGen.header - sourceText += - """ - extension \(decl.name): FishyActorTransport.MessageRecipient { - """ + sourceText += """ + extension \(decl.name): FishyActorTransport.MessageRecipient { + """ sourceText += "\n" // ==== Generate message representation, // In our sample representation we do so by: @@ -76,34 +71,34 @@ final class SourceGen { // -- emit a `case` that represents the function // sourceText += """ - enum _Message: Sendable, Codable { - - """ + enum _Message: Sendable, Codable { - for fun in decl.funcs { - sourceText += "case \(fun.name)" - guard !fun.params.isEmpty else { - sourceText += "\n" - continue - } - sourceText += "(" - - var first = true - for (label, _, type) in fun.params { - sourceText += first ? "" : ", " - if let label = label, label != "_" { - sourceText += "\(label): \(type)" - } else { - sourceText += type - } - - first = false - } + """ - sourceText += ")\n" + for fun in decl.funcs { + sourceText += " case \(fun.name)" + guard !fun.params.isEmpty else { + sourceText += "\n" + continue + } + sourceText += "(" + + var first = true + for (label, _, type) in fun.params { + sourceText += first ? "" : ", " + if let label = label, label != "_" { + sourceText += "\(label): \(type)" + } else { + sourceText += type } - sourceText += "}\n" + first = false + } + + sourceText += ")\n" + } + + sourceText += " }\n \n" // ==== Generate the "receive"-side, we must decode the incoming Envelope // into a _Message and apply it to our local actor. // @@ -111,142 +106,140 @@ final class SourceGen { // specifically the serialization logic does not have to live in here as // long as we get hold of the type we need to deserialize. sourceText += """ - nonisolated func _receiveAny( - envelope: Envelope, encoder: Encoder, decoder: Decoder - ) async throws -> Encoder.Output - where Encoder: TopLevelEncoder, Decoder: TopLevelDecoder { - let message = try decoder.decode(_Message.self, from: envelope.message as! Decoder.Input) // TODO: this needs restructuring to avoid the cast, we need to know what types we work with - return try await self._receive(message: message, encoder: encoder) - } - - nonisolated func _receive( - message: _Message, encoder: Encoder - ) async throws -> Encoder.Output where Encoder: TopLevelEncoder { - do { - switch message { - + nonisolated func _receiveAny( + envelope: Envelope, encoder: Encoder, decoder: Decoder + ) async throws -> Encoder.Output + where Encoder: TopLevelEncoder, Decoder: TopLevelDecoder { + let message = try decoder.decode(_Message.self, from: envelope.message as! Decoder.Input) // TODO: this needs restructuring to avoid the cast, we need to know what types we work with + return try await self._receive(message: message, encoder: encoder) + } + + nonisolated func _receive( + message: _Message, encoder: Encoder + ) async throws -> Encoder.Output where Encoder: TopLevelEncoder { + do { + switch message { """ + + for fun in decl.funcs { + sourceText += "\n case .\(fun.name)\(fun.parameterMatch):\n" + sourceText += " " + + if fun.result != "Void" { + sourceText += "let result = " + } - for fun in decl.funcs { - sourceText += "case .\(fun.name)\(fun.parameterMatch):\n" - - if fun.result != "Void" { - sourceText += "let result = " - } - - sourceText += "try await self.\(fun.name)(" + sourceText += "try await self.\(fun.name)(" - sourceText += fun.params.map { param in - let (label, name, _) = param - if let label = label, label != "_" { - return "\(label): \(name)" - } - return name - }.joined(separator: ", ") + sourceText += fun.params.map { param in + let (label, name, _) = param + if let label = label, label != "_" { + return "\(label): \(name)" + } + return name + }.joined(separator: ", ") - sourceText += ")\n" + sourceText += ")\n" - let returnValue = fun.result == "Void" ? "Optional.none" : "result" + let returnValue = fun.result == "Void" ? "Optional.none" : "result" - sourceText += "return try encoder.encode(\(returnValue))\n\n" - } + sourceText += " return try encoder.encode(\(returnValue))\n" + } - sourceText += """ + sourceText += """ + } + } catch { + fatalError("Error handling not implemented; \\(error)") } - } catch { - fatalError("Error handling not implemented; \\(error)") } - } """ - sourceText += "\n" - sourceText += decl.funcs.map { $0.dynamicReplacementFunc }.joined(separator: "\n\n") + sourceText += "\n \n" + sourceText += decl.funcs.map { $0.dynamicReplacementFunc }.joined(separator: "\n \n") - sourceText += "\n" + sourceText += "\n" sourceText += """ } """ - sourceText += "\n" - sourceText += "\n" - - let handle = try FileHandle(forWritingTo: file) - handle.seekToEndOfFile() - handle.write(Data(sourceText.utf8)) - try handle.synchronize() - handle.closeFile() + return sourceText } } +struct GeneratedSource { + let text: String + let bucket: Int +} + extension FuncDecl { - var dynamicReplacementFunc: String { - """ - @_dynamicReplacement(for: _remote_\(name)(\(prototype))) - nonisolated func _fishy_\(name)(\(argumentList)) async throws \(funcReturn) { - let message = Self._Message.\(name)\(messageArguments) - return try await requireFishyTransport.send(message, to: self.id, expecting: \(result).self) - } - """ - } + var dynamicReplacementFunc: String { + """ + @_dynamicReplacement(for: _remote_\(name)(\(prototype))) + nonisolated func _fishy_\(name)(\(argumentList)) async throws \(funcReturn) { + let message = Self._Message.\(name)\(messageArguments) + return try await requireFishyTransport.send(message, to: self.id, expecting: \(result).self) + } + """ + } - var funcReturn: String { - return result != "Void" ? "-> \(result)" : "" - } + var funcReturn: String { + return result != "Void" ? "-> \(result)" : "" + } - var prototype: String { - params.map { param in - let (label, name, _) = param - var result = "" - result += label ?? name - result += ":" - return result - }.joined() - } + var prototype: String { + params.map { param in + let (label, name, _) = param + var result = "" + result += label ?? name + result += ":" + return result + }.joined() + } - var argumentList: String { - params.map { param in - let (label, name, type) = param - var result = "" + var argumentList: String { + params.map { param in + let (label, name, type) = param + var result = "" - if let label = label { - result += label - } + if let label = label { + result += label + } - if name != label { - result += " \(name)" - } + if name != label { + result += " \(name)" + } - result += ": \(type)" + result += ": \(type)" + + return result + }.joined(separator: ", ") + } - return result - }.joined(separator: ", ") + var messageArguments: String { + guard !params.isEmpty else { + return "" } - var messageArguments: String { - guard !params.isEmpty else { - return "" - } + return "(" + params.map { param in + let (label, name, _) = param + if let label = label, label != "_" { + return "\(label): \(name)" + } else { + return name + } - return "(" + params.map { param in - let (label, name, _) = param - if let label = label, label != "_" { - return "\(label): \(name)" - } else { - return name - } + }.joined(separator: ", ") + ")" + } - }.joined(separator: ", ") + ")" + var parameterMatch: String { + guard !params.isEmpty else { + return "" } - var parameterMatch: String { - guard !params.isEmpty else { - return "" - } - - return "(" + params.map { param in - let (_, name, _) = param - return "let \(name)" - }.joined(separator: ", ") + ")" - } + return "(" + params.map { param in + let (_, name, _) = param + return "let \(name)" + }.joined(separator: ", ") + ")" + } } diff --git a/FishyTransport/Sources/FishyActorsGenerator/main.swift b/FishyTransport/Sources/FishyActorsGenerator/main.swift index 287bf19..def29d6 100644 --- a/FishyTransport/Sources/FishyActorsGenerator/main.swift +++ b/FishyTransport/Sources/FishyActorsGenerator/main.swift @@ -44,14 +44,25 @@ struct FishyActorsGeneratorMain: ParsableCommand { print("Generate extensions...") } - let sourceGen = SourceGen(targetDirectory: targetDirectory, buckets: buckets) + let sourceGen = SourceGen(buckets: buckets) + + // TODO: Don't do this + // Just make sure all "buckets" exist (don't remove this + // until you can honor the requested ammount of buckets) + for i in (0.. \(targetFilePath(targetDirectory: targetDirectory, i: 1))") + print(" Generated 'FishyActorTransport' extensions for 'distributed actor \(decl.name)' -> \(filePath)") } - _ = sourceGen.generate(decl: decl) + + try source.text.write(to: filePath, atomically: true, encoding: .utf8) } - } } diff --git a/FishyTransport/Tests/FishyActorsGeneratorTests/GeneratorTest.swift b/FishyTransport/Tests/FishyActorsGeneratorTests/GeneratorTest.swift new file mode 100644 index 0000000..04a11b5 --- /dev/null +++ b/FishyTransport/Tests/FishyActorsGeneratorTests/GeneratorTest.swift @@ -0,0 +1,255 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the swift-sample-distributed-actors-transport open source project +// +// Copyright (c) 2018 Apple Inc. and the swift-sample-distributed-actors-transport project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of swift-sample-distributed-actors-transport project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest +@testable import FishyActorsGenerator + +final class GeneratorTest: XCTestCase { + // Generate source code given example DistributedActorDecl's and compare to the expected generated code + func test_generate_source() async throws { + let generator = SourceGen(buckets: 1) + + for (actorDecl, expectedSource) in zip(TestConstants.actorDeclarations, TestConstants.expectedSources) { + let generatedSource = try generator.generate(decl: actorDecl).text + + if generatedSource != expectedSource { + print("\nDifferences detected between generated and expected sources for actor \(actorDecl.name)") + printDifferenceInBuckets(expected: expectedSource, generated: generatedSource) + + print("\nGenerated source:") + print(generatedSource) + print("EOF\n") + XCTFail() + } + } + } + + // Print the insertions and removals in the correct order to make the generated buckets match the expected ones + private func printDifferenceInBuckets(expected expectedSource: String, generated generatedSource: String) { + let splitExpectedBucket = expectedSource.split(separator: "\n", omittingEmptySubsequences: false) + let splitGeneratedBucket = generatedSource.split(separator: "\n", omittingEmptySubsequences: false) + let difference = splitExpectedBucket.difference(from: splitGeneratedBucket) + + for dif in difference { + switch dif { + case .insert(offset: let index, element: let line, associatedWith: _): + print(" + line \(index + 1): \"\(line)\"") + case .remove(offset: let index, element: let line, associatedWith: _): + print(" - line \(index + 1): \"\(line)\"") + } + } + } +} + +private struct TestConstants { + // Obtained from Analysis on directory with distributed actors + static let actorDeclarations: [DistributedActorDecl] = [ + DistributedActorDecl( + access: AccessControl.internal, + name: "ChatRoom", + funcs: [ + FuncDecl( + access: AccessControl.internal, + name: "join", + params: [(Optional("chatter"), "chatter", "Chatter")], + throwing: false, + async: false, + result: "String" + ), + FuncDecl( + access: AccessControl.internal, + name: "message", + params: [(Optional("_"), "message", "String"), (Optional("from"), "chatter", "Chatter")], + throwing: false, + async: false, + result: "Void" + ), + FuncDecl( + access: AccessControl.internal, + name: "leave", + params: [(Optional("chatter"), "chatter", "Chatter")], + throwing: false, + async: false, + result: "Void") + ] + ), + DistributedActorDecl( + access: AccessControl.internal, + name: "Chatter", + funcs: [ + FuncDecl( + access: AccessControl.internal, + name: "join", + params: [(Optional("room"), "room", "ChatRoom")], + throwing: true, + async: true, + result: "Void"), + FuncDecl( + access: AccessControl.internal, + name: "chatterJoined", + params: [(Optional("room"), "room", "ChatRoom"), (Optional("chatter"), "chatter", "Chatter")], + throwing: true, + async: true, + result: "Void"), + FuncDecl( + access: AccessControl.internal, + name: "chatRoomMessage", + params: [(Optional("_"), "message", "String"), (Optional("from"), "chatter", "Chatter")], + throwing: false, + async: false, + result: "Void") + ] + ) + ] + static let expectedSources: [String] = [ + #""" + // DO NOT MODIFY: This file will be re-generated automatically. + // Source generated by FishyActorsGenerator (version x.y.z) + import _Distributed + + import FishyActorTransport + import ArgumentParser + import Logging + + import func Foundation.sleep + import struct Foundation.Data + import class Foundation.JSONDecoder + + extension ChatRoom: FishyActorTransport.MessageRecipient { + enum _Message: Sendable, Codable { + case join(chatter: Chatter) + case message(String, from: Chatter) + case leave(chatter: Chatter) + } + + nonisolated func _receiveAny( + envelope: Envelope, encoder: Encoder, decoder: Decoder + ) async throws -> Encoder.Output + where Encoder: TopLevelEncoder, Decoder: TopLevelDecoder { + let message = try decoder.decode(_Message.self, from: envelope.message as! Decoder.Input) // TODO: this needs restructuring to avoid the cast, we need to know what types we work with + return try await self._receive(message: message, encoder: encoder) + } + + nonisolated func _receive( + message: _Message, encoder: Encoder + ) async throws -> Encoder.Output where Encoder: TopLevelEncoder { + do { + switch message { + case .join(let chatter): + let result = try await self.join(chatter: chatter) + return try encoder.encode(result) + + case .message(let message, let chatter): + try await self.message(message, from: chatter) + return try encoder.encode(Optional.none) + + case .leave(let chatter): + try await self.leave(chatter: chatter) + return try encoder.encode(Optional.none) + } + } catch { + fatalError("Error handling not implemented; \(error)") + } + } + + @_dynamicReplacement(for: _remote_join(chatter:)) + nonisolated func _fishy_join(chatter: Chatter) async throws -> String { + let message = Self._Message.join(chatter: chatter) + return try await requireFishyTransport.send(message, to: self.id, expecting: String.self) + } + + @_dynamicReplacement(for: _remote_message(_:from:)) + nonisolated func _fishy_message(_ message: String, from chatter: Chatter) async throws { + let message = Self._Message.message(message, from: chatter) + return try await requireFishyTransport.send(message, to: self.id, expecting: Void.self) + } + + @_dynamicReplacement(for: _remote_leave(chatter:)) + nonisolated func _fishy_leave(chatter: Chatter) async throws { + let message = Self._Message.leave(chatter: chatter) + return try await requireFishyTransport.send(message, to: self.id, expecting: Void.self) + } + } + """#, + #""" + // DO NOT MODIFY: This file will be re-generated automatically. + // Source generated by FishyActorsGenerator (version x.y.z) + import _Distributed + + import FishyActorTransport + import ArgumentParser + import Logging + + import func Foundation.sleep + import struct Foundation.Data + import class Foundation.JSONDecoder + + extension Chatter: FishyActorTransport.MessageRecipient { + enum _Message: Sendable, Codable { + case join(room: ChatRoom) + case chatterJoined(room: ChatRoom, chatter: Chatter) + case chatRoomMessage(String, from: Chatter) + } + + nonisolated func _receiveAny( + envelope: Envelope, encoder: Encoder, decoder: Decoder + ) async throws -> Encoder.Output + where Encoder: TopLevelEncoder, Decoder: TopLevelDecoder { + let message = try decoder.decode(_Message.self, from: envelope.message as! Decoder.Input) // TODO: this needs restructuring to avoid the cast, we need to know what types we work with + return try await self._receive(message: message, encoder: encoder) + } + + nonisolated func _receive( + message: _Message, encoder: Encoder + ) async throws -> Encoder.Output where Encoder: TopLevelEncoder { + do { + switch message { + case .join(let room): + try await self.join(room: room) + return try encoder.encode(Optional.none) + + case .chatterJoined(let room, let chatter): + try await self.chatterJoined(room: room, chatter: chatter) + return try encoder.encode(Optional.none) + + case .chatRoomMessage(let message, let chatter): + try await self.chatRoomMessage(message, from: chatter) + return try encoder.encode(Optional.none) + } + } catch { + fatalError("Error handling not implemented; \(error)") + } + } + + @_dynamicReplacement(for: _remote_join(room:)) + nonisolated func _fishy_join(room: ChatRoom) async throws { + let message = Self._Message.join(room: room) + return try await requireFishyTransport.send(message, to: self.id, expecting: Void.self) + } + + @_dynamicReplacement(for: _remote_chatterJoined(room:chatter:)) + nonisolated func _fishy_chatterJoined(room: ChatRoom, chatter: Chatter) async throws { + let message = Self._Message.chatterJoined(room: room, chatter: chatter) + return try await requireFishyTransport.send(message, to: self.id, expecting: Void.self) + } + + @_dynamicReplacement(for: _remote_chatRoomMessage(_:from:)) + nonisolated func _fishy_chatRoomMessage(_ message: String, from chatter: Chatter) async throws { + let message = Self._Message.chatRoomMessage(message, from: chatter) + return try await requireFishyTransport.send(message, to: self.id, expecting: Void.self) + } + } + """#, + ] +}