Basic project creation (#3)

This PR contains the work done to create a new *Hummingbird* project with very basic configuration from the _colibri_ executable, just like the project you could create with the [Hummingbird template](https://github.com/hummingbird-project/template) project in Github.

Reviewed-on: #3
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #3.
This commit is contained in:
2025-01-28 00:07:24 +00:00
committed by Javier Cicchelli
parent b8c354e614
commit 9be8fa4a31
52 changed files with 1936 additions and 475 deletions
@@ -0,0 +1,74 @@
enum File: String {
case app = "App"
case appArguments = "AppArguments"
case appBuilder = "AppBuilder"
case appOptions = "AppOptions"
case appTests = "AppTests"
case dockerFile = "DockerFile"
case dockerIgnore = "DockerIgnore"
case environment = "Environment"
case gitIgnore = "GitIgnore"
case license = "License"
case loggerLevel = "LoggerLevel"
case package = "Package"
case readme = "Readme"
case testArguments = "TestArguments"
}
// MARK: - Properties
extension File {
// MARK: Computed
var fileName: String {
switch self {
case .app: "App.swift"
case .appArguments: "AppArguments.swift"
case .appBuilder: "AppBuilder.swift"
case .appOptions: "AppOptions.swift"
case .appTests: "AppTests.swift"
case .dockerFile: "Dockerfile"
case .dockerIgnore: ".dockerignore"
case .environment: "Environment+Properties.swift"
case .gitIgnore: ".gitignore"
case .license: "LICENSE"
case .loggerLevel: "LoggerLevel+Conformances.swift"
case .readme: "README.md"
case .package: "Package.swift"
case .testArguments: "TestArguments.swift"
}
}
var filePath: String {
folder.path + fileName
}
var folder: Folder {
switch self {
case .app, .appOptions: .app
case .appArguments, .appBuilder: .libraryPublic
case .appTests: .testCasesPublic
case .environment, .loggerLevel: .libraryInternal
case .testArguments: .testHelpers
default: .root
}
}
var resourcePath: String {
let basePath = "Resources/Files/Sources"
return switch self {
case .app, .appOptions: "\(basePath)/App"
case .appArguments, .appBuilder, .environment, .loggerLevel: "\(basePath)/Library"
case .appTests, .testArguments: "\(basePath)/Test"
default: basePath
}
}
}
// MARK: - CaseIterable
extension File: CaseIterable {}
@@ -0,0 +1,48 @@
enum Folder {
case app
case libraryPublic
case libraryInternal
case root
case testCasesPublic
case testCasesInternal
case testHelpers
}
// MARK: - Properties
extension Folder {
// MARK: Computed
var path: String {
switch self {
case .app: "App/Sources/"
case .libraryPublic: "Library/Sources/Public/"
case .libraryInternal: "Library/Sources/Internal/"
case .root: ""
case .testCasesPublic: "Test/Sources/Cases/Public/"
case .testCasesInternal: "Test/Sources/Cases/Internal/"
case .testHelpers: "Test/Sources/Helpers/"
}
}
}
// MARK: - CaseIterable
extension Folder: CaseIterable {
// MARK: Properties
static var allCases: [Folder] {[
.app,
.libraryPublic,
.libraryInternal,
.testCasesPublic,
.testCasesInternal,
.testHelpers
]}
static var allCasesWithRoot: [Folder] { [.root] + Folder.allCases }
}
@@ -0,0 +1,5 @@
import Foundation
// MARK: - Bundleable
extension Bundle: Bundleable {}
@@ -0,0 +1,72 @@
import Foundation
extension Pipe {
// MARK: Computed
var availableData: AsyncAvailableData { .init(self) }
}
// MARK: - AsyncAvailableData
extension Pipe {
struct AsyncAvailableData {
// MARK: Properties
private let pipe: Pipe
// MARK: Initialisers
init(_ pipe: Pipe) {
self.pipe = pipe
}
// MARK: Functions
func append() async -> Data {
var data = Data()
for await availableData in self {
data.append(availableData)
}
return data
}
}
}
// MARK: - AsyncSequence
extension Pipe.AsyncAvailableData: AsyncSequence {
// MARK: Type aliases
typealias AsyncIterator = AsyncStream<Data>.Iterator
typealias Element = Data
// MARK: Functions
func makeAsyncIterator() -> AsyncIterator {
AsyncStream { continuation in
pipe.fileHandleForReading.readabilityHandler = { @Sendable handler in
let data = handler.availableData
guard !data.isEmpty else {
continuation.finish()
return
}
continuation.yield(data)
}
continuation.onTermination = { _ in
pipe.fileHandleForReading.readabilityHandler = nil
}
}
.makeAsyncIterator()
}
}
@@ -0,0 +1,5 @@
import Foundation
// MARK: - Processable
extension Process: Processable {}
@@ -0,0 +1,35 @@
import Foundation
extension URL {
// MARK: Initialisers
init(at filePath: String) {
if #available(macOS 13.0, *) {
self = URL(filePath: filePath)
} else {
self = URL(fileURLWithPath: filePath)
}
}
// MARK: Computed
var pathString: String {
if #available(macOS 13.0, *) {
path(percentEncoded: true)
} else {
path
}
}
// MARK: Functions
func appendingPath(_ path: String) -> URL {
if #available(macOS 13.0, *) {
appending(path: path)
} else {
appendingPathComponent(path)
}
}
}
@@ -0,0 +1,17 @@
import Foundation
protocol Processable {
// MARK: Properties
var arguments: [String]? { get set }
var executableURL: URL? { get set }
var standardError: Any? { get set }
var standardOutput: Any? { get set }
var terminationHandler: (@Sendable (Process) -> Void)? { get set }
// MARK: Functions
func run() throws
}
@@ -0,0 +1,75 @@
import Foundation
struct RunProcessTask {
// MARK: Type aliases
typealias Output = String
// MARK: Properties
private var process: Processable
// MARK: Initialisers
init(process: Processable) {
self.process = process
}
// MARK: Functions
@discardableResult
mutating func callAsFunction(
path: String, arguments: [String] = []
) async throws (RunProcessError) -> Output {
process.executableURL = URL(at: path)
process.arguments = arguments
let pipeError = Pipe()
let pipeOutput = Pipe()
process.standardError = pipeError
process.standardOutput = pipeOutput
async let streamOutput = pipeOutput.availableData.append()
async let streamError = pipeError.availableData.append()
do {
try process.run()
let dataOutput = await streamOutput
let dataError = await streamError
guard dataError.isEmpty else {
guard let errorOutput = String(data: dataError, encoding: .utf8) else {
throw RunProcessError.unexpected
}
throw RunProcessError.output(errorOutput)
}
guard let output = String(data: dataOutput, encoding: .utf8) else {
throw RunProcessError.unexpected
}
return await withCheckedContinuation { continuation in
process.terminationHandler = { _ in
continuation.resume(returning: output)
}
}
} catch let error as RunProcessError {
throw error
} catch {
throw RunProcessError.captured(error.localizedDescription)
}
}
}
// MARK: - Errors
public enum RunProcessError: Error, Equatable {
case captured(_ output: String)
case output(_ output: String)
case unexpected
}
@@ -0,0 +1,9 @@
import Foundation
public protocol Bundleable {
// MARK: Functions
func url(forResource name: String?, withExtension ext: String?, subdirectory subpath: String?) -> URL?
}
@@ -0,0 +1,28 @@
import Foundation
public protocol FileServicing {
// MARK: Computed
var currentFolder: URL { get async }
// MARK: Functions
func copyFile(from source: URL, to destination: URL) async throws (FileServiceError)
func createFolder(at location: URL) async throws (FileServiceError)
func deleteItem(at location: URL) async throws (FileServiceError)
func isItemExists(at location: URL) async throws (FileServiceError) -> Bool
}
// MARK: - Errors
public enum FileServiceError: Error, Equatable {
case folderNotCreated
case itemAlreadyExists
case itemEmptyData
case itemNotCopied
case itemNotDeleted
case itemNotExists
case itemNotFileURL
}
@@ -0,0 +1,79 @@
import Foundation
public struct FileService: FileServicing {
// MARK: Properties
private let fileManager: FileManager
// MARK: Initialisers
public init(fileManager: FileManager = .default) {
self.fileManager = fileManager
}
// MARK: Computed
public var currentFolder: URL {
get async {
.init(at: fileManager.currentDirectoryPath)
}
}
// MARK: Functions
public func copyFile(from source: URL, to destination: URL) async throws (FileServiceError) {
guard try await !isItemExists(at: destination) else {
throw FileServiceError.itemAlreadyExists
}
var itemData: Data?
do {
itemData = try Data(contentsOf: source)
} catch {
throw FileServiceError.itemEmptyData
}
do {
try itemData?.write(to: destination, options: .atomic)
} catch {
throw FileServiceError.itemNotCopied
}
}
public func createFolder(at location: URL) async throws (FileServiceError) {
guard try await !isItemExists(at: location) else {
throw FileServiceError.itemAlreadyExists
}
do {
try fileManager.createDirectory(at: location, withIntermediateDirectories: true)
} catch {
throw FileServiceError.folderNotCreated
}
}
public func deleteItem(at location: URL) async throws (FileServiceError) {
guard try await isItemExists(at: location) else {
throw FileServiceError.itemNotExists
}
do {
try fileManager.removeItem(at: location)
} catch {
throw FileServiceError.itemNotDeleted
}
}
public func isItemExists(at location: URL) async throws (FileServiceError) -> Bool {
guard location.isFileURL else {
throw FileServiceError.itemNotFileURL
}
let filePath = location.pathString
return fileManager.fileExists(atPath: filePath)
}
}
@@ -0,0 +1,39 @@
import Foundation
public struct CopyFilesTask {
// MARK: Properties
private let bundle: Bundleable
private let fileService: FileServicing
// MARK: Initialisers
public init(
bundle: Bundleable? = nil,
fileService: FileServicing
) {
self.bundle = bundle ?? Bundle.module
self.fileService = fileService
}
// MARK: Functions
public func callAsFunction(to rootFolder: URL) async throws (FileServiceError) {
for file in File.allCases {
guard let source = bundle.url(
forResource: file.rawValue,
withExtension: nil,
subdirectory: file.resourcePath
) else {
assertionFailure("URL should have been initialized.")
return
}
let destination = rootFolder.appendingPath(file.filePath)
try await fileService.copyFile(from: source, to: destination)
}
}
}
@@ -0,0 +1,25 @@
import Foundation
public struct CreateFoldersTask {
// MARK: Properties
private let fileService: FileServicing
// MARK: Initialisers
public init(fileService: FileServicing) {
self.fileService = fileService
}
// MARK: Functions
public func callAsFunction(at rootFolder: URL) async throws {
let folders = Folder.allCases.map { rootFolder.appendingPath($0.path) }
for folder in folders {
try await fileService.createFolder(at: folder)
}
}
}
@@ -0,0 +1,41 @@
import Foundation
public struct CreateRootFolderTask {
// MARK: Properties
private let fileService: FileServicing
// MARK: Initialisers
public init(fileService: FileServicing) {
self.fileService = fileService
}
// MARK: Functions
public func callAsFunction(name: String, at location: URL? = nil) async throws -> URL {
guard !name.isEmpty else {
throw CreateRootFolderError.nameIsEmpty
}
let rootFolder = if let location {
location
} else {
await fileService.currentFolder
}
let newFolder = rootFolder.appendingPath(name)
try await fileService.createFolder(at: newFolder)
return newFolder
}
}
// MARK: - Errors
public enum CreateRootFolderError: Error {
case nameIsEmpty
}
@@ -0,0 +1,24 @@
import Foundation
public struct InitGitInFolderTask {
// MARK: Initialisers
public init() {}
// MARK: Functions
public func callAsFunction(at rootFolder: URL) async throws (RunProcessError) {
let pathCommand = "/usr/bin/git"
let pathFolder = rootFolder.pathString
var gitInit = RunProcessTask(process: Process())
var gitAdd = RunProcessTask(process: Process())
var gitCommit = RunProcessTask(process: Process())
try await gitInit(path: pathCommand, arguments: ["init", pathFolder])
try await gitAdd(path: pathCommand, arguments: ["-C", pathFolder, "add", "."])
try await gitCommit(path: pathCommand, arguments: ["-C", pathFolder, "commit", "-m", "Initial commit"])
}
}