diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Serial.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Serial.xcscheme new file mode 100644 index 0000000..91ae4b5 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Serial.xcscheme @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SerialDSL-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SerialDSL-Package.xcscheme new file mode 100644 index 0000000..b2d25d6 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/SerialDSL-Package.xcscheme @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SerialDSL.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SerialDSL.xcscheme new file mode 100644 index 0000000..908356f --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/SerialDSL.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d0fb04c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug Serial", + "program": "${workspaceFolder:swift-Serial-DSL}/.build/debug/Serial", + "args": [], + "cwd": "${workspaceFolder:swift-Serial-DSL}", + "preLaunchTask": "swift: Build Debug Serial" + }, + { + "type": "lldb", + "request": "launch", + "name": "Release Serial", + "program": "${workspaceFolder:swift-Serial-DSL}/.build/release/Serial", + "args": [], + "cwd": "${workspaceFolder:swift-Serial-DSL}", + "preLaunchTask": "swift: Build Release Serial" + } + ] +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e13c7ac --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +export EXECUTABLE_NAME = serial + +PREFIX = /usr/local +INSTALL_PATH = $(PREFIX)/bin/$(EXECUTABLE_NAME) +SHARE_PATH = $(PREFIX)/share/$(EXECUTABLE_NAME) +CURRENT_PATH = $(PWD) +SWIFT_BUILD_FLAGS = --disable-sandbox -c release --arch arm64 --arch x86_64 +EXECUTABLE_PATH = $(shell swift build $(SWIFT_BUILD_FLAGS) --show-bin-path)/$(EXECUTABLE_NAME) + +.PHONY: install build uninstall format_code brew release + +install: build + mkdir -p $(PREFIX)/bin + cp -f $(EXECUTABLE_PATH) $(INSTALL_PATH) + +build: + swift build $(SWIFT_BUILD_FLAGS) diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..0d2f30a --- /dev/null +++ b/Package.resolved @@ -0,0 +1,32 @@ +{ + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "9f39744e025c7d377987f30b03770805dcb0bcd1", + "version" : "1.1.4" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "836bc4557b74fe6d2660218d56e3ce96aff76574", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-tools-support-core", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-tools-support-core.git", + "state" : { + "revision" : "0b77e67c484e532444ceeab60119b8536f8cd648", + "version" : "0.3.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index aecb681..a7d5975 100644 --- a/Package.swift +++ b/Package.swift @@ -6,26 +6,40 @@ import PackageDescription let package = Package( name: "SerialDSL", platforms: [ - .macOS(.v10_13), + .macOS(.v10_15), .iOS(.v13), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "SerialDSL", + type: .dynamic, targets: ["SerialDSL"] - ) + ), + .executable(name: "Serial", targets: ["Serial", "SerialDSL"]) ], dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.0"), +// .package(url: "https://github.com/apple/swift-package-manager", branch: "main"), + .package(url: "https://github.com/apple/swift-tools-support-core.git", from: "0.2.7"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "SerialDSL", - dependencies: [] + dependencies: [], + swiftSettings: [ + .unsafeFlags(["-enable-library-evolution"]) + ] + ), + .executableTarget( + name: "Serial", + dependencies: [ + "SerialDSL", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "SwiftToolsSupport", package: "swift-tools-support-core"), + ] ), .testTarget( name: "SerialDSLTests", diff --git a/Sources/Fixture.swift b/Sources/Fixture.swift new file mode 100644 index 0000000..d6a954e --- /dev/null +++ b/Sources/Fixture.swift @@ -0,0 +1,49 @@ +import SerialDSL +import Foundation + +struct Record: SerialView { + + let name: String + let age: Int + + var body: some SerialView { + Object { + Member("name") { + name + } + Member("age") { + age + } + } + } + +} + +struct Results: SerialView { + + let records: [Record] + + var body: some SerialView { + Object { + Member("results") { + records + } + } + } + +} + +let results = Results(records: [ + .init(name: "A", age: 1), + .init(name: "B", age: 2), +]) + +serialize { + + SerialObject { + SerialMember("data") { + results + } + } + +} diff --git a/Sources/Serial/CLI.swift b/Sources/Serial/CLI.swift new file mode 100644 index 0000000..afdd47e --- /dev/null +++ b/Sources/Serial/CLI.swift @@ -0,0 +1,365 @@ +import ArgumentParser +import Foundation +import TSCBasic +import TSCUtility + +let RUNTIME_NAME = "SerialDSL" + +enum Log { + + static func debug( + file: StaticString = #file, + line: UInt = #line, + _ log: OSLog, + _ object: @autoclosure () -> Any + ) { + print(object()) + } + + static func error( + file: StaticString = #file, + line: UInt = #line, + _ log: OSLog, + _ object: @autoclosure () -> Any + ) { + os_log(.info, log: log, "%{public}@\n%{public}@:%{public}@", "\(object())", "\(file)", "\(line.description)") + } + +} + +extension OSLog { + + @inline(__always) + private static func makeOSLogInDebug(isEnabled: Bool = true, _ factory: () -> OSLog) -> OSLog { +#if DEBUG + return factory() +#else + return .disabled +#endif + } + + static let generic: OSLog = makeOSLogInDebug { OSLog.init(subsystem: "app.muukii", category: "generic") } +} + +struct CLIError: Swift.Error, LocalizedError, Equatable { + + var errorDescription: String? + + static var fileNotFound: Self = .init(errorDescription: "File not found") + static var runtimeNotFound: Self = .init(errorDescription: "Runtime not found") + +} + +struct CLI: AsyncParsableCommand { + + static var configuration: CommandConfiguration = .init(subcommands: [Gen.self]) + + struct Gen: AsyncParsableCommand { + + struct DomainError: Swift.Error, LocalizedError, Equatable { + + var errorDescription: String? + + static var failedToCompile: Self = .init(errorDescription: "Failed to compile") + static var couldNotCreateOutputFile: Self = .init(errorDescription: "Could not create output file") + static var failureInMakingOutput: Self = .init(errorDescription: "Failure in makiing output") + + } + + @Argument var targetFilePath: String + + mutating func run() async throws { + + let filePath = localFileSystem.currentWorkingDirectory!.appending( + RelativePath(targetFilePath) + ) + + guard localFileSystem.exists(filePath) else { + throw CLIError.fileNotFound + } + + let foundPath = try TSCBasic.Process.checkNonZeroExit(arguments: [ + "/usr/bin/xcrun", "--find", "swiftc", + ]).spm_chomp() + + let swiftc = try AbsolutePath(validating: foundPath) + + let applicationPath = try Utils.hostBinDir(fileSystem: localFileSystem) + + var runtimeFrameworksPath: AbsolutePath { + + if localFileSystem.exists(applicationPath.appending(component: "lib\(RUNTIME_NAME).dylib")) { + return applicationPath + } + + return applicationPath.appending( + components: "PackageFrameworks", + "\(RUNTIME_NAME).framework" + ) + } + + var libraryPath: AbsolutePath { + if runtimeFrameworksPath.extension == "framework" { + return runtimeFrameworksPath.appending(component: RUNTIME_NAME) + } else { + // note: this is not correct for all platforms, but we only actually use it on macOS. + return runtimeFrameworksPath.appending(component: "lib\(RUNTIME_NAME).dylib") + } + } + +// Log.debug(.generic, """ +//applicationPath: \(applicationPath) +//runtimeFrameworksPath: \(runtimeFrameworksPath) +//""") + + guard localFileSystem.exists(libraryPath) else { + throw CLIError.runtimeNotFound + } + + let target = try Utils.computeMinimumDeploymentTarget( + of: libraryPath + ) + + let sdkPath = try Utils.sdk() + + var cmd: [String] = [] + cmd += [swiftc.pathString] + + if runtimeFrameworksPath.extension == "framework" { + cmd += [ + "-F", runtimeFrameworksPath.parentDirectory.pathString, + "-framework", RUNTIME_NAME, + "-Xlinker", "-rpath", "-Xlinker", runtimeFrameworksPath.parentDirectory.pathString, + ] + } else { + cmd += [ + "-L", runtimeFrameworksPath.pathString, + "-l\(RUNTIME_NAME)", + "-Xlinker", "-rpath", "-Xlinker", runtimeFrameworksPath.pathString + ] + } + cmd += ["-target", "arm64-apple-macosx\(target!.versionString)"] + + cmd += ["-sdk", sdkPath.pathString] + cmd += Utils.flags() + + cmd += [filePath.pathString] + cmd += [ + "-Xfrontend", "-disable-implicit-concurrency-module-import", + "-Xfrontend", "-disable-implicit-string-processing-module-import", + "-I", applicationPath.pathString, + ] + + try await withTemporaryDirectory { workingPath in + + let compiledFile = workingPath.appending(component: "compiled") + + // make a binary + do { + + cmd += ["-o", compiledFile.pathString] + + // compile + let result = try await TSCBasic.Process.popen( + arguments: cmd, + environment: ProcessInfo.processInfo.environment, + loggingHandler: { log in } + ) + + // Return now if there was an error. + if result.exitStatus != .terminated(code: 0) { + let output = try result.utf8stderrOutput() + Log.debug(.generic, "\(output)\n\(cmd.joined(separator: " "))") + throw DomainError.failedToCompile + } + + } + + // make an output + do { + + let outputFile = workingPath.appending(component: "output") + + guard let outputFileDesc = fopen(outputFile.pathString, "w") else { + throw DomainError.couldNotCreateOutputFile + } + + var cmd: [String] = [] + + cmd += [compiledFile.pathString] + + cmd += ["-fileno", "\(fileno(outputFileDesc))"] + + let result = try await TSCBasic.Process.popen(arguments: cmd, environment: ProcessInfo.processInfo.environment, loggingHandler: { log in }) + + fclose(outputFileDesc) + + // Return now if there was an error. + if result.exitStatus != .terminated(code: 0) { + + let output = try result.utf8stderrOutput() + Log.debug(.generic, "\(output)\n\(cmd.joined(separator: " "))") + + throw DomainError.failureInMakingOutput + } + + let output: String = try localFileSystem.readFileContents(outputFile) + + print(output) + + } + + } + } + + } + +} + +func withTemporaryDirectory(_ work: @escaping (AbsolutePath) async throws -> Void) async throws { + + try await withCheckedThrowingContinuation { continuation in + + do { + try withTemporaryDirectory { path, completion -> Void in + Task { + do { + try await work(path) + completion(path) + continuation.resume() + } catch { + print(error) + // handle error + completion(path) + continuation.resume(throwing: error) + } + } + } + } catch { + continuation.resume(throwing: error) + } + } + +} + +extension TSCBasic.Process { + + static public func popen( + arguments: [String], + environment: [String: String] = ProcessEnv.vars, + loggingHandler: LoggingHandler? = .none + ) async throws -> ProcessResult { + + try await withCheckedThrowingContinuation { continuation in + + self.popen( + arguments: arguments, + environment: environment, + loggingHandler: loggingHandler, + queue: nil + ) { result in + + switch result { + case .success(let r): + continuation.resume(returning: r) + case .failure(let e): + continuation.resume(throwing: e) + } + + } + + } + + } + +} + +public typealias EnvironmentVariables = [String: String] + +enum Utils { + + static func computeMinimumDeploymentTarget(of binaryPath: AbsolutePath) throws -> PlatformVersion? + { + + let platformName = "MACOS" + + let runResult = try Process.popen(arguments: [ + "/usr/bin/xcrun", "vtool", "-show-build", binaryPath.pathString, + ]) + var lines = try runResult.utf8Output().components(separatedBy: "\n") + while !lines.isEmpty { + let first = lines.removeFirst() + if first.contains("platform \(platformName)"), let line = lines.first, line.contains("minos") + { + return line.components(separatedBy: " ").last.map(PlatformVersion.init(stringLiteral:)) + } + } + return nil + } + + static func flags() -> [String] { + // Compute common arguments for clang and swift. + var extraCCFlags: [String] = [] + var extraSwiftCFlags: [String] = [] + + if let sdkPaths = sdkPlatformFrameworkPaths(environment: ProcessInfo.processInfo.environment) { + extraCCFlags += ["-F", sdkPaths.fwk.pathString] + extraSwiftCFlags += ["-F", sdkPaths.fwk.pathString] + extraSwiftCFlags += ["-I", sdkPaths.lib.pathString] + extraSwiftCFlags += ["-L", sdkPaths.lib.pathString] + } + return extraSwiftCFlags + + } + + static func sdkPlatformFrameworkPaths( + environment: EnvironmentVariables = ProcessInfo.processInfo.environment + ) -> (fwk: AbsolutePath, lib: AbsolutePath)? { + + let platformPath = try? TSCBasic.Process.checkNonZeroExit( + arguments: ["/usr/bin/xcrun", "--sdk", "macosx", "--show-sdk-platform-path"], + environment: environment + ).spm_chomp() + + if let platformPath = platformPath, !platformPath.isEmpty { + // For XCTest framework. + let fwk = AbsolutePath(platformPath).appending( + components: "Developer", + "Library", + "Frameworks" + ) + + // For XCTest Swift library. + let lib = AbsolutePath(platformPath).appending( + components: "Developer", + "usr", + "lib" + ) + + return (fwk, lib) + } + return nil + } + + static func sdk() throws -> AbsolutePath { + let path = try TSCBasic.Process.checkNonZeroExit( + arguments: ["/usr/bin/xcrun", "--sdk", "macosx", "--show-sdk-path"], + environment: ProcessEnv.vars + ).spm_chomp() + return AbsolutePath(path) + } + + static func hostBinDir( + fileSystem: FileSystem + ) throws -> AbsolutePath { + + return try AbsolutePath(validating: (Bundle.main.executablePath! as NSString).resolvingSymlinksInPath).parentDirectory + } +} + +@main +enum Main { + static func main() async { + await CLI.main() + } +} diff --git a/Sources/Serial/FileSystem+.swift b/Sources/Serial/FileSystem+.swift new file mode 100644 index 0000000..dc45f2d --- /dev/null +++ b/Sources/Serial/FileSystem+.swift @@ -0,0 +1,24 @@ +import TSCBasic +import Foundation + +extension FileSystem { + public func readFileContents(_ path: AbsolutePath) throws -> Data { + return try Data(self.readFileContents(path).contents) + } + + public func readFileContents(_ path: AbsolutePath) throws -> String { + return try String(decoding: self.readFileContents(path), as: UTF8.self) + } + + public func writeFileContents(_ path: AbsolutePath, data: Data) throws { + return try self.writeFileContents(path, bytes: .init(data)) + } + + public func writeFileContents(_ path: AbsolutePath, string: String) throws { + return try self.writeFileContents(path, bytes: .init(encodingAsUTF8: string)) + } + + public func writeFileContents(_ path: AbsolutePath, provider: () -> String) throws { + return try self.writeFileContents(path, string: provider()) + } +} diff --git a/Sources/Serial/PlatformVersion.swift b/Sources/Serial/PlatformVersion.swift new file mode 100644 index 0000000..91cdc55 --- /dev/null +++ b/Sources/Serial/PlatformVersion.swift @@ -0,0 +1,55 @@ +import TSCBasic +import TSCUtility + +/// Represents a platform version. +public struct PlatformVersion: Equatable, Hashable, Codable { + + /// The unknown platform version. + public static let unknown: PlatformVersion = .init("0.0.0") + + /// The underlying version storage. + private let version: Version + + /// The string representation of the version. + public var versionString: String { + var str = "\(version.major).\(version.minor)" + if version.patch != 0 { + str += ".\(version.patch)" + } + return str + } + + public var major: Int { version.major } + public var minor: Int { version.minor } + public var patch: Int { version.patch } + + /// Create a platform version given a string. + /// + /// The platform version is expected to be in format: X.X.X + public init(_ version: String) { + let components = version.split(separator: ".").compactMap({ Int($0) }) + assert(!components.isEmpty && components.count <= 3, version) + switch components.count { + case 1: + self.version = Version(components[0], 0, 0) + case 2: + self.version = Version(components[0], components[1], 0) + case 3: + self.version = Version(components[0], components[1], components[2]) + default: + fatalError("Unexpected number of components \(components)") + } + } +} + +extension PlatformVersion: Comparable { + public static func < (lhs: PlatformVersion, rhs: PlatformVersion) -> Bool { + return lhs.version < rhs.version + } +} + +extension PlatformVersion: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self.init(value) + } +} diff --git a/Sources/SerialDSL/Entrypoint.swift b/Sources/SerialDSL/Entrypoint.swift new file mode 100644 index 0000000..d267f0b --- /dev/null +++ b/Sources/SerialDSL/Entrypoint.swift @@ -0,0 +1,21 @@ +@_implementationOnly import Darwin.C + +public func serialize(@ValueBuilder _ thunk: () -> some SerialView) { + + let value = thunk() + + let text = value.renderJSON() + + if let optIdx = CommandLine.arguments.firstIndex(of: "-fileno") { + + if let outputFileDesc = Int32(CommandLine.arguments[optIdx + 1]) { + guard let fd = fdopen(outputFileDesc, "w") else { + return + } + fputs(text, fd) + fclose(fd) + } + } else { + print(text) + } +}