Skip to content

Commit 878875e

Browse files
committed
Basic project CLI
1 parent c3d6aba commit 878875e

7 files changed

Lines changed: 265 additions & 38 deletions

File tree

Sources/Compiler/Project.swift

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//
2+
// Project.swift
3+
// Feather
4+
//
5+
// Created by Wes Wickwire on 5/21/25.
6+
//
7+
8+
import Foundation
9+
10+
public struct Project {
11+
public let url: URL
12+
public let migrationsDirectory: URL
13+
public let queriesDirectory: URL
14+
private let fileSystem: FileSystem
15+
16+
public init(url: URL) {
17+
self = Project(url: url, fileSystem: FileManager.default)
18+
}
19+
20+
init(url: URL, fileSystem: FileSystem) {
21+
self.url = url
22+
self.fileSystem = fileSystem
23+
self.migrationsDirectory = url.appendingPathComponent("Migrations")
24+
self.queriesDirectory = url.appendingPathComponent("Queries")
25+
}
26+
27+
public var doesMigrationsExist: Bool {
28+
fileSystem.exists(at: migrationsDirectory)
29+
}
30+
31+
public var doesQueriesExist: Bool {
32+
fileSystem.exists(at: queriesDirectory)
33+
}
34+
35+
public func setup() throws {
36+
try fileSystem.create(directory: migrationsDirectory)
37+
try fileSystem.create(directory: queriesDirectory)
38+
}
39+
40+
public func doesQueryExist(withName name: String) -> Bool {
41+
let fileUrl = queriesDirectory.appendingPathComponent("\(name).sql")
42+
return fileSystem.exists(at: fileUrl)
43+
}
44+
45+
public func addQuery(named name: String) throws {
46+
guard let data = "".data(using: .utf8) else {
47+
fatalError("Failed to get blank string data")
48+
}
49+
50+
let fileUrl = queriesDirectory.appendingPathComponent("\(name).sql")
51+
52+
fileSystem.write(data, to: fileUrl)
53+
}
54+
55+
public func addMigration() throws {
56+
guard let data = "".data(using: .utf8) else {
57+
fatalError("Failed to get blank string data")
58+
}
59+
60+
let latestMigration = try fileSystem.files(at: migrationsDirectory)
61+
.compactMap { $0.split(separator: ".").first }
62+
.compactMap{ Int($0) }
63+
.sorted(by: >)
64+
.first ?? 0
65+
66+
let fileUrl = migrationsDirectory.appendingPathComponent("\(latestMigration + 1).sql")
67+
68+
fileSystem.write(data, to: fileUrl)
69+
}
70+
}

Sources/Compiler/Utils/FileSystem.swift

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,34 @@ protocol FileSystem {
1717
func contents(of path: String) throws -> String
1818
func modificationDate(of path: String) throws -> Date?
1919
func create(directory: String) throws
20+
func write(_ data: Data, to path: String)
21+
func exists(at path: String) -> Bool
22+
}
23+
24+
extension FileSystem {
25+
func files(at url: URL) throws -> [String] {
26+
try files(atPath: url.path)
27+
}
28+
29+
func contents(of url: URL) throws -> String {
30+
try contents(of: url.path)
31+
}
32+
33+
func modificationDate(of url: URL) throws -> Date? {
34+
try modificationDate(of: url.path)
35+
}
36+
37+
func create(directory: URL) throws {
38+
try create(directory: directory.path)
39+
}
40+
41+
func write(_ data: Data, to url: URL) {
42+
write(data, to: url.path)
43+
}
44+
45+
func exists(at url: URL) -> Bool {
46+
exists(at: url.path)
47+
}
2048
}
2149

2250
extension FileManager: FileSystem {
@@ -56,12 +84,19 @@ extension FileManager: FileSystem {
5684
}
5785

5886
func create(directory: String) throws {
59-
guard !FileManager.default
60-
.fileExists(atPath: directory) else { return }
87+
guard !fileExists(atPath: directory) else { return }
6188

6289
try FileManager.default.createDirectory(
6390
atPath: directory,
6491
withIntermediateDirectories: true
6592
)
6693
}
94+
95+
func write(_ data: Data, to path: String) {
96+
createFile(atPath: path, contents: data)
97+
}
98+
99+
func exists(at path: String) -> Bool {
100+
fileExists(atPath: path)
101+
}
67102
}

Sources/FeatherCLI/Feather.swift

Lines changed: 16 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -12,42 +12,22 @@ import SwiftSyntax
1212

1313
@main
1414
struct Feather: AsyncParsableCommand {
15-
@Option(name: .shortAndLong, help: "The root directory of the Feather sources")
16-
var path: String = FileManager.default.currentDirectoryPath
17-
18-
@Option(name: .shortAndLong, help: "The output file path. Default is to stdout")
19-
var output: String? = nil
20-
21-
@Option(name: .shortAndLong, help: "The database name")
22-
var databaseName: String = "DB"
23-
24-
@Option(name: .shortAndLong, help: "Comma separated list of additional imports to add")
25-
var additionalImports: String?
26-
27-
@Flag var dontColorize = false
28-
29-
mutating func run() async throws {
30-
let options = GenerationOptions(
31-
databaseName: databaseName,
32-
imports: additionalImports?.split(separator: ",").map(\.description) ?? []
33-
)
34-
35-
try await generate(language: SwiftLanguage.self, options: options)
36-
}
15+
static let configuration = CommandConfiguration(
16+
subcommands: [GenCommand.self, InitCommand.self, MigrateCommand.self, QueriesCommand.self],
17+
defaultSubcommand: GenCommand.self
18+
)
19+
}
20+
21+
enum FeatherError: Error, CustomStringConvertible {
22+
case sourcesNotFound
23+
case queryAlreadyExists(fileName: String)
3724

38-
private func generate<Lang: Language>(
39-
language: Lang.Type,
40-
options: GenerationOptions
41-
) async throws {
42-
let driver = Driver()
43-
await driver.add(reporter: StdoutDiagnosticReporter(dontColorize: dontColorize))
44-
45-
try await driver.compile(path: path)
46-
47-
try await driver.generate(
48-
language: Lang.self,
49-
to: output,
50-
options: options
51-
)
25+
var description: String {
26+
switch self {
27+
case .sourcesNotFound:
28+
"Sources not found, run init to initialize new project"
29+
case .queryAlreadyExists(let fileName):
30+
"Query file with name '\(fileName)' already exists"
31+
}
5232
}
5333
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//
2+
// GenCommand.swift
3+
// Feather
4+
//
5+
// Created by Wes Wickwire on 5/21/25.
6+
//
7+
8+
import Foundation
9+
import ArgumentParser
10+
import Compiler
11+
import SwiftSyntax
12+
13+
struct GenCommand: AsyncParsableCommand {
14+
static let configuration = CommandConfiguration(commandName: "gen")
15+
16+
@Option(name: .shortAndLong, help: "The root directory of the Feather sources")
17+
var path: String = FileManager.default.currentDirectoryPath
18+
19+
@Option(name: .shortAndLong, help: "The output file path. Default is to stdout")
20+
var output: String? = nil
21+
22+
@Option(name: .shortAndLong, help: "The database name")
23+
var databaseName: String = "DB"
24+
25+
@Option(name: .shortAndLong, help: "Comma separated list of additional imports to add")
26+
var additionalImports: String?
27+
28+
@Flag var dontColorize = false
29+
30+
mutating func run() async throws {
31+
let options = GenerationOptions(
32+
databaseName: databaseName,
33+
imports: additionalImports?.split(separator: ",").map(\.description) ?? []
34+
)
35+
36+
try await generate(language: SwiftLanguage.self, options: options)
37+
}
38+
39+
private func generate<Lang: Language>(
40+
language: Lang.Type,
41+
options: GenerationOptions
42+
) async throws {
43+
let driver = Driver()
44+
await driver.add(reporter: StdoutDiagnosticReporter(dontColorize: dontColorize))
45+
46+
try await driver.compile(path: path)
47+
48+
try await driver.generate(
49+
language: Lang.self,
50+
to: output,
51+
options: options
52+
)
53+
}
54+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//
2+
// InitCommand.swift
3+
// Feather
4+
//
5+
// Created by Wes Wickwire on 5/21/25.
6+
//
7+
8+
import ArgumentParser
9+
import Foundation
10+
import Compiler
11+
12+
struct InitCommand: ParsableCommand {
13+
static let configuration = CommandConfiguration(commandName: "init")
14+
15+
func run() throws {
16+
let project = Project(url: URL(fileURLWithPath: FileManager.default.currentDirectoryPath))
17+
try project.setup()
18+
try project.addMigration()
19+
}
20+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//
2+
// MigrateCommand.swift
3+
// Feather
4+
//
5+
// Created by Wes Wickwire on 5/21/25.
6+
//
7+
8+
import ArgumentParser
9+
import Foundation
10+
import Compiler
11+
12+
struct MigrateCommand: ParsableCommand {
13+
static let configuration = CommandConfiguration(
14+
commandName: "migrate",
15+
subcommands: [Add.self]
16+
)
17+
18+
struct Add: ParsableCommand {
19+
static let configuration = CommandConfiguration(commandName: "add")
20+
21+
func run() throws {
22+
let project = Project(url: URL(fileURLWithPath: FileManager.default.currentDirectoryPath))
23+
24+
guard project.doesMigrationsExist else {
25+
throw FeatherError.sourcesNotFound
26+
}
27+
28+
try project.addMigration()
29+
}
30+
}
31+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//
2+
// QueriesCommand.swift
3+
// Feather
4+
//
5+
// Created by Wes Wickwire on 5/21/25.
6+
//
7+
8+
import ArgumentParser
9+
import Foundation
10+
import Compiler
11+
12+
struct QueriesCommand: ParsableCommand {
13+
static let configuration = CommandConfiguration(
14+
commandName: "queries",
15+
subcommands: [Add.self]
16+
)
17+
18+
struct Add: ParsableCommand {
19+
static let configuration = CommandConfiguration(commandName: "add")
20+
21+
@Argument var name: String
22+
23+
func run() throws {
24+
let project = Project(url: URL(fileURLWithPath: FileManager.default.currentDirectoryPath))
25+
26+
guard project.doesQueriesExist else {
27+
throw FeatherError.sourcesNotFound
28+
}
29+
30+
guard !project.doesQueryExist(withName: name) else {
31+
throw FeatherError.queryAlreadyExists(fileName: name)
32+
}
33+
34+
try project.addQuery(named: name)
35+
}
36+
}
37+
}

0 commit comments

Comments
 (0)