Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci_generator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
paths:
- '.github/workflows/ci_generator.yml'
- 'generator/**'
- 'bin/test_generator.sh'
- 'bin/test-generator.sh'
push:
branches:
- main
Expand All @@ -32,7 +32,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Run generator tests
run: ./bin/test_generator.sh
run: ./bin/test-generator.sh

run-generator-usage-tests-macos:
name: Test generator usage on macOS ${{ matrix.macOS }} with Xcode ${{ matrix.xcode }}
Expand All @@ -49,4 +49,4 @@ jobs:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Run generator tests
run: ./bin/test_generator.sh
run: ./bin/test-generator.sh
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ bin/configlet
bin/configlet.exe

# Xcode
#
.swiftpm
build/
*.pbxuser
Expand All @@ -22,7 +21,10 @@ DerivedData
*.xcuserstate
.DS_Store

#Idea IDE
# VS Code
.vscode

# Idea IDE
.idea

# Swift
Expand Down
4 changes: 2 additions & 2 deletions bin/test_generator.sh → bin/test-generator.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ for exercise_path in "${PRACTICE_DIR}"/*; do

exercise_name="${exercise_path##*/}"

if ! swift run --package-path "${GENERATOR_DIR}" Generator "${exercise_name}" "${TEMP_DIR}"; then
if ! swift run --package-path "${GENERATOR_DIR}" Generator "${exercise_name}" --exercise-path "${TEMP_DIR}" > /dev/null 2>&1; then
printf 'Generation failed for %s\n' "${exercise_name}"
exit_code=1
continue
Expand All @@ -39,4 +39,4 @@ for exercise_path in "${PRACTICE_DIR}"/*; do
fi
done

exit "${exit_code}"
exit "${exit_code}"
54 changes: 29 additions & 25 deletions generator/Package.swift
Original file line number Diff line number Diff line change
@@ -1,30 +1,34 @@
// swift-tools-version:5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
// swift-tools-version:6.2

import PackageDescription

let package = Package(
name: "Generator",
platforms: [
.macOS(.v12) // Set the minimum macOS version to 10.15 or any version greater than 10.15.
],
dependencies: [
.package(url: "https://github.com/stencilproject/Stencil.git", from: "0.15.1"),
.package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.5.5"),
.package(url: "https://github.com/apple/swift-format", from: "600.0.0"),
],
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 which this package depends on.
.target(
name: "Generator",
dependencies: [
.product(name: "Stencil", package: "Stencil"),
.product(name: "TOMLKit", package: "TOMLKit"),
.product(name: "SwiftFormat", package: "swift-format"),
]),
.testTarget(
name: "GeneratorTests",
dependencies: ["Generator"]),
]
name: "Generator",
platforms: [
.macOS(.v13) // macOS 13.0 (Ventura) or later.
],
dependencies: [
.package(url: "https://github.com/stencilproject/Stencil.git", from: "0.15.1"),
.package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.6.0"),
.package(url: "https://github.com/apple/swift-format", from: "602.0.0"),
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.6.1"),
],
targets: [
.executableTarget(
name: "Generator",
dependencies: [
.product(name: "Stencil", package: "Stencil"),
.product(name: "TOMLKit", package: "TOMLKit"),
.product(name: "SwiftFormat", package: "swift-format"),
.product(name: "ArgumentParser", package: "swift-argument-parser")
]
),
.testTarget(
name: "GeneratorTests",
dependencies: ["Generator"],
resources: [
.copy("Resources")
]
),
]
)
21 changes: 21 additions & 0 deletions generator/Sources/Generator/Extensions/URL+Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

extension URL {

func validateFileExists() throws {
var isDirectory: ObjCBool = true
let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
guard exists && !isDirectory.boolValue else {
throw GeneratorError.noFile("No such file: \(path)")
}
}

func validateDirectoryExists() throws {
var isDirectory: ObjCBool = true
let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
guard exists && isDirectory.boolValue else {
throw GeneratorError.noDirectory("No such directory: \(path)")
}
}

}
128 changes: 128 additions & 0 deletions generator/Sources/Generator/Generator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import Foundation
import ArgumentParser
import SwiftFormat

enum GeneratorError: Error, Equatable {
case noDirectory(String)
case noFile(String)
case noExercise(String)
case remoteError(String)
case internalError(String)
}

enum ExerciseKind: String, CaseIterable, ExpressibleByArgument {
case practice
case concept
}

@main
struct Generator: AsyncParsableCommand {

// MARK: - Static Properties

static let configuration = CommandConfiguration(
commandName: "Generator",
abstract: "A Swift track tool for generating Swift test files for an exercise."
)

// MARK: - Arguments

@Argument(
help: "The slug of the exercise to process."
)
var exerciseSlug: String

@Option(
name: [.short, .long],
help: "The kind of exercise to process. Possible values: \(ExerciseKind.allCases.map { $0.rawValue }.joined(separator: ", "))"
)
var exerciseKind: ExerciseKind = .practice

@Option(
help: """
The absolute or relative path to the exercise within the track directory.
Will use exercise kind and track path to calculate if not specified.
""",
transform: { URL(filePath: $0).standardizedFileURL }
)
var exercisePath: URL?

@Option(
help: "The absolute or relative path to the track directory. Defaults to the current directory.",
transform: { URL(filePath: $0).standardizedFileURL }
)
var trackDirectoryPath: URL = URL(filePath: FileManager.default.currentDirectoryPath)

// MARK: - Private Properties

private lazy var exerciseDirectoryURL: URL = {
guard let exercisePath else {
return trackDirectoryPath.appending(components: "exercises", "\(exerciseKind.rawValue)", "\(exerciseSlug)")
}
return exercisePath
}()

private lazy var trackConfigURL: URL = { trackDirectoryPath.appending(components: "config.json") }()
private lazy var exerciseConfigURL: URL = { exerciseDirectoryURL.appending(components: ".meta", "config.json") }()
private lazy var templateURL: URL = { exerciseDirectoryURL.appending(components: ".meta", "template.swift") }()
private lazy var tomlURL: URL = { exerciseDirectoryURL.appending(components: ".meta", "tests.toml") }()

// MARK: - Internal Methods

mutating func validate() throws {
try trackDirectoryPath.validateDirectoryExists()
try trackConfigURL.validateFileExists()

try exerciseDirectoryURL.validateDirectoryExists()
try exerciseConfigURL.validateFileExists()
try templateURL.validateFileExists()
try tomlURL.validateFileExists()

try validateExerciseExist()
}

mutating func run() async throws {
let context = try await makeTestContext()
try generateTestFile(context: context)
}

// MARK: - Private Methods

private mutating func validateExerciseExist() throws {
let trackConfig = try TrackConfig(from: trackConfigURL)
guard trackConfig.checkExistance(slug: exerciseSlug, kind: exerciseKind) else {
throw GeneratorError.noExercise("No exercise found for \(exerciseSlug) in \(trackConfigURL.path)")
}
}

private mutating func makeTestContext() async throws -> [String: Any] {
var canonicalData = try await CanonicalData.fetch(slug: exerciseSlug)
let toml = try TOMLConfig(from: tomlURL)
canonicalData.whitelistTests(withUUIDs: toml.uuids)
return canonicalData.context
}

private mutating func generateTestFile(context: [String: Any]) throws {
let renderedString = try Stencil.render(template: templateURL, context: context) // canonicalData.jsonData)

let testTargetURL = try getTargetTestFileURL()

var outputText = ""
let configuration = Configuration()
let swiftFormat = SwiftFormatter(configuration: configuration)
try swiftFormat.format(
source: renderedString,
assumingFileURL: nil,
selection: .infinite,
to: &outputText
)
try outputText.write(toFile: testTargetURL.path, atomically: true, encoding: .utf8)
}

private mutating func getTargetTestFileURL() throws -> URL {
let config = try ExerciseConfig(from: exerciseConfigURL)
let testFile = exerciseDirectoryURL.appending(component: try config.getTargetTestFileURL())
return testFile
}

}
61 changes: 61 additions & 0 deletions generator/Sources/Generator/Model/CanonicalData.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

struct CanonicalData {

var context: [String: Any]

init(dictionary: [String: Any]) {
self.context = dictionary
}

mutating func whitelistTests(withUUIDs uuidsToKeep: Set<String>) {
if let cases = context["cases"] as? [[String: Any]] {
context["cases"] = whitelistTests(from: cases, withUUIDs: uuidsToKeep)
}
}

private func whitelistTests(from cases: [[String: Any]], withUUIDs uuidsToKeep: Set<String>) -> [[String: Any]] {
return cases.compactMap { caseData in
var caseData = caseData

if let uuid = caseData["uuid"] as? String {
return uuidsToKeep.contains(uuid) ? caseData : nil
} else if let nestedCases = caseData["cases"] as? [[String: Any]] {
let nestedWhitelisted = whitelistTests(from: nestedCases, withUUIDs: uuidsToKeep)
if !nestedWhitelisted.isEmpty {
caseData["cases"] = nestedWhitelisted
return caseData
}
}

return nil
}
}

}

extension CanonicalData {

static func fetch(slug: String) async throws -> CanonicalData {
print("Loading canonical data for \"\(slug)\"...", terminator: "")
let urlString = "https://raw.githubusercontent.com/exercism/problem-specifications/master/exercises/\(slug)/canonical-data.json"
guard let url = URL(string: urlString) else {
throw GeneratorError.remoteError("Invalid URL for exercise \(slug)")
}

let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw GeneratorError.remoteError("HTTP error with code: \((response as? HTTPURLResponse)?.statusCode ?? -1)")
}
print("OK!")
guard let jsonData = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw GeneratorError.remoteError("Invalid canonical data format")
}

return .init(dictionary: jsonData)
}

}
24 changes: 24 additions & 0 deletions generator/Sources/Generator/Model/Config.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Foundation

protocol Config: Decodable {

init(from url: URL) throws
init(from string: String) throws

}

extension Config {

init(from url: URL) throws {
try self.init(data: Data(contentsOf: url))
}

init(from string: String) throws {
try self.init(data: Data(string.utf8))
}

private init(data: Data) throws {
self = try JSONDecoder().decode(Self.self, from: data)
}

}
20 changes: 20 additions & 0 deletions generator/Sources/Generator/Model/ExerciseConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Foundation

struct ExerciseConfig: Config {

struct Files: Codable {
let solution: [String]
let test: [String]
let example: [String]
}

let files: Files

func getTargetTestFileURL() throws -> String {
if files.test.isEmpty {
throw GeneratorError.internalError("Exercise config file has an unexpected format or no test files are defined.")
}
return files.test.first!
}

}
Loading