diff --git a/.github/workflows/ci_generator.yml b/.github/workflows/ci_generator.yml index 2998f33fc..8d28a873b 100644 --- a/.github/workflows/ci_generator.yml +++ b/.github/workflows/ci_generator.yml @@ -5,7 +5,7 @@ on: paths: - '.github/workflows/ci_generator.yml' - 'generator/**' - - 'bin/test_generator.sh' + - 'bin/test-generator.sh' push: branches: - main @@ -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 }} @@ -49,4 +49,4 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Run generator tests - run: ./bin/test_generator.sh \ No newline at end of file + run: ./bin/test-generator.sh diff --git a/.gitignore b/.gitignore index c052a9b8c..02614d88f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ bin/configlet bin/configlet.exe # Xcode -# .swiftpm build/ *.pbxuser @@ -22,7 +21,10 @@ DerivedData *.xcuserstate .DS_Store -#Idea IDE +# VS Code +.vscode + +# Idea IDE .idea # Swift diff --git a/bin/test_generator.sh b/bin/test-generator.sh similarity index 92% rename from bin/test_generator.sh rename to bin/test-generator.sh index edd2aedca..e0bda4a54 100755 --- a/bin/test_generator.sh +++ b/bin/test-generator.sh @@ -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 @@ -39,4 +39,4 @@ for exercise_path in "${PRACTICE_DIR}"/*; do fi done -exit "${exit_code}" \ No newline at end of file +exit "${exit_code}" diff --git a/generator/Package.swift b/generator/Package.swift index 63701123f..4a20ff249 100644 --- a/generator/Package.swift +++ b/generator/Package.swift @@ -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") + ] + ), + ] ) diff --git a/generator/Sources/Generator/Extensions/URL+Extensions.swift b/generator/Sources/Generator/Extensions/URL+Extensions.swift new file mode 100644 index 000000000..c5d336eaf --- /dev/null +++ b/generator/Sources/Generator/Extensions/URL+Extensions.swift @@ -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)") + } + } + +} diff --git a/generator/Sources/Generator/Generator.swift b/generator/Sources/Generator/Generator.swift new file mode 100644 index 000000000..f436cc96d --- /dev/null +++ b/generator/Sources/Generator/Generator.swift @@ -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 + } + +} diff --git a/generator/Sources/Generator/Model/CanonicalData.swift b/generator/Sources/Generator/Model/CanonicalData.swift new file mode 100644 index 000000000..48a2bdb6a --- /dev/null +++ b/generator/Sources/Generator/Model/CanonicalData.swift @@ -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) { + 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: 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) + } + +} diff --git a/generator/Sources/Generator/Model/Config.swift b/generator/Sources/Generator/Model/Config.swift new file mode 100644 index 000000000..68cd55e7c --- /dev/null +++ b/generator/Sources/Generator/Model/Config.swift @@ -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) + } + +} diff --git a/generator/Sources/Generator/Model/ExerciseConfig.swift b/generator/Sources/Generator/Model/ExerciseConfig.swift new file mode 100644 index 000000000..bf5369e63 --- /dev/null +++ b/generator/Sources/Generator/Model/ExerciseConfig.swift @@ -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! + } + +} diff --git a/generator/Sources/Generator/Model/TomlConfig.swift b/generator/Sources/Generator/Model/TomlConfig.swift new file mode 100644 index 000000000..d1a0d5210 --- /dev/null +++ b/generator/Sources/Generator/Model/TomlConfig.swift @@ -0,0 +1,31 @@ +import Foundation +import TOMLKit + +struct TOMLConfig: Config { + + let uuids: Set + + init(from url: URL) throws { + try self.init(from: String(contentsOf: url, encoding: .utf8)) + } + + init(from string: String) throws { + let table = try TOMLTable(string: string) + var uuids = Set() + for (key, value) in table { + guard + let properties = value.table, + properties.contains(key: "include") + else { + uuids.insert(key) + continue + } + + if properties["include"] as? Bool == true { + uuids.insert(key) + } + } + self.uuids = uuids + } + +} diff --git a/generator/Sources/Generator/Model/TrackConfig.swift b/generator/Sources/Generator/Model/TrackConfig.swift new file mode 100644 index 000000000..d1aa03407 --- /dev/null +++ b/generator/Sources/Generator/Model/TrackConfig.swift @@ -0,0 +1,32 @@ +import Foundation + +struct TrackConfig: Config { + + struct Exercise: Decodable { + let slug: String + let uuid: String + } + + struct Exercises: Decodable { + let concept: [Exercise] + let practice: [Exercise] + } + + let exercises: Exercises + + func checkExistance(slug: String, kind: ExerciseKind) -> Bool { + exercises[kind].contains { $0.slug == slug } + } + +} + +extension TrackConfig.Exercises { + + fileprivate subscript(kind: ExerciseKind) -> [TrackConfig.Exercise] { + switch kind { + case .concept: concept + case .practice: practice + } + } + +} diff --git a/generator/Sources/Generator/Stencil.swift b/generator/Sources/Generator/Stencil.swift new file mode 100644 index 000000000..ac53dc298 --- /dev/null +++ b/generator/Sources/Generator/Stencil.swift @@ -0,0 +1,306 @@ +import Foundation +import Stencil + +enum Stencil { + + private static let escapeChars = ["\t": "\\t", "\n": "\\n", "\r": "\\r", "\\": "\\\\", "\"": "\\\""] + + static func render(template: URL, context: [String: Any]) throws -> String { + let templateString = try String(contentsOf: template, encoding: .utf8) + let pluginsEnvironment = Stencil.registerPlugins() + return try pluginsEnvironment.renderTemplate(string: templateString, context: context) + } + + private static func registerPlugins() -> Environment { + let ext = Extension() + + ext.registerFilter("isNull") { (value: Any?) in + return NSNull().isEqual(value) + } + + ext.registerFilter("jsonString") { (value: Any?) in + guard let value = value as? [String: Any] else { return nil } + let json = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) + guard let jsonString = String(data: json, encoding: .utf8) else { return nil } + return jsonString.map { Self.escapeChars[String($0)] ?? String($0) }.joined() + } + + ext.registerFilter("camelCase") { (value: Any?) in + if let inputString = value as? String { + let charactersToRemove: [Character] = [ + ",", "'", "?", "!", ".", "=", "+", "&", "%", "$", "#", "@", "(", ")", "[", "]", "{", "}", + "<", ">", "/", "|", ":", ";", + ] + let filteredString = inputString.filter { !charactersToRemove.contains($0) } + let components = filteredString.components(separatedBy: CharacterSet(charactersIn: " -")) + let capitalizedComponents = components.map { $0.capitalized } + let camelCaseString = capitalizedComponents.joined() + return camelCaseString + } + return nil + } + + ext.registerFilter("contains") { (value: Any?, args: [Any?]) in + if let inputString = value as? String { + if let substring = args.first as? String { + return inputString.contains(substring) + } + } else if let inputArray = value as? [String] { + if let string = args.first as? String { + return inputArray.contains(string) + } + } else if let inputArray = value as? [Int] { + if let number = args.first as? Int { + return inputArray.contains(number) + } + } else if let inputDict = value as? [String: Any] { + if let string = args.first as? String { + return inputDict.keys.contains(string) + } + } + return false + } + + ext.registerFilter("any") { (value: Any?, args: [Any?]) in + if let inputArray = value as? [Int] { + if let rule = args.first as? String { + switch rule { + case "isEven": + return inputArray.contains { $0 % 2 == 0 } + case "isOdd": + return inputArray.contains { $0 % 2 != 0 } + case "isNegative": + return inputArray.contains { $0 < 0 } + case "isPositive": + return inputArray.contains { $0 > 0 } + default: + return false + } + } + } + return false + } + + ext.registerFilter("toStringArray") { (value: Any?) in + if let inputString = value as? [String] { + guard !inputString.isEmpty else { return "[]" } + return "[\"\(inputString.joined(separator: "\", \""))\"]" + } else if let array = value as? [Any?] { + return array + } + + return nil + } + + ext.registerFilter("toStringDictionary") { (value: Any?) in + if let inputDictionary = value as? [String: String] { + guard !inputDictionary.isEmpty else { return "[:]" } + var output = "[" + for (key, value) in inputDictionary.sorted(by: { $0.0 < $1.0 }) { + output += "\"\(key)\": \"\(value)\", " + } + output.removeLast(2) + output += "]" + return output + } + if let inputDictionary = value as? [String: Int] { + guard !inputDictionary.isEmpty else { return "[:]" } + var output = "[" + for (key, value) in inputDictionary.sorted(by: { $0.0 < $1.0 }) { + output += "\"\(key)\": \(value), " + } + output.removeLast(2) + output += "]" + return output + } + if let inputDictionary = value as? [String: [String]] { + guard !inputDictionary.isEmpty else { return "[:]" } + var output = "[" + for (key, value) in inputDictionary.sorted(by: { $0.0 < $1.0 }) { + output += "\"\(key)\": [\"\(value.joined(separator: "\", \""))\"], " + } + output.removeLast(2) + output += "]" + return output + } + return nil + } + + ext.registerFilter("inspect") { (value: Any?) in + if let inputString = value as? String { + return inputString.map { Self.escapeChars[String($0)] ?? String($0) }.joined() + } + return nil + } + + ext.registerFilter("minus") { (value: Any?, args: [Any?]) in + if let inputNumber = value as? Int { + if let number = args.first as? Int { + return inputNumber - number + } + } + return nil + } + + ext.registerFilter("toTupleArray") { (value: Any?) in + if let inputArray = value as? [[String]] { + guard !inputArray.isEmpty else { return "[]" } + var output = "[" + for array in inputArray { + output += "(\(array.map { "\"\($0)\"" }.joined(separator: ", "))), " + } + output.removeLast(2) + output += "]" + return output + } + if let inputArray = value as? [[Int]] { + guard !inputArray.isEmpty else { return "[]" } + var output = "[" + for array in inputArray { + output += "(\(array.map { "\($0)" }.joined(separator: ", "))), " + } + output.removeLast(2) + output += "]" + return output + } + + return nil + } + + ext.registerFilter("extractCountKey") { (value: Any?) in + if let inputDictionary = value as? [String: Any] { + if let count = inputDictionary["count"] as? Int { + return count + } + } + return nil + } + + ext.registerFilter("toNilArray") { (value: Any?) in + func replaceValuesWithNil(_ array: [Any?]) -> [Any?] { + var result = [Any?]() + for element in array { + if let nestedArray = element as? [Any?] { + result.append(replaceValuesWithNil(nestedArray)) + } else { + if NSNull().isEqual(element) { + result.append(nil) + } else { + result.append(element) + } + + } + } + + return result + } + if let inputArray = value as? [Any?] { + return replaceValuesWithNil(inputArray) + } + + return nil + } + + ext.registerFilter("length") { (value: Any?) in + if let inputString = value as? String { + return inputString.count + } + if let inputArray = value as? [Any?] { + return inputArray.count + } + if let inputDictionary = value as? [String: Any?] { + return inputDictionary.count + } + return nil + } + + ext.registerFilter("toEnumArray") { (value: Any?) in + if let inputArray = value as? [String] { + guard !inputArray.isEmpty else { return "[]" } + var output = "[" + for element in inputArray { + output += ".\(element), " + } + output.removeLast(2) + output += "]" + return output + } + return nil + } + + ext.registerFilter("strain") { (value: Any?) in + if let input = value as? String { + if input.contains("starts_with") { + return "{x in x.starts(with: \"z\")}" + } else if input.contains("contains") { + return "{x in x.contains(5)}" + } + let trimmedInput = input.replacingOccurrences(of: "fn(x) -> ", with: "") + return "{x in \(trimmedInput)}" + } + return [] + } + + ext.registerFilter("round") { (value: Any?, args: [Any?]) in + if let inputNumber = value as? Int { + return inputNumber + } + if let inputNumber = value as? Double { + if let precision = args.first as? Int { + let divisor = pow(10.0, Double(precision)) + return (inputNumber * divisor).rounded() / divisor + } + } + return nil + } + + ext.registerFilter("knapsackItem") { (value: Any?) in + if let item = value as? [String: Any] { + let itemWeight = String(describing: item["weight", default: ""]) + let itemValue = String(describing: item["value", default: ""]) + return "Item(weight:\(itemWeight), value:\(itemValue))" + } + return "// Something else ..." + } + + ext.registerFilter("complexNumber") { (value: Any?) in + if let input = value as? String { + switch input { + case "pi": + return "Double.pi" + case "e": + return "exp(1)" + case "ln(2)": + return "log(2)" + case "ln(2)/2": + return "log(2)/2" + case "pi/4": + return "Double.pi/4" + default: + return input + } + } + return value + } + + ext.registerFilter("listOps") { (value: Any?) in + if let inputString = value as? String { + return inputString.replacingOccurrences(of: "foldl", with: "foldLeft") + .replacingOccurrences(of: "foldr", with: "foldRight") + } + return nil + } + + ext.registerFilter("defaultArray") { (value: Any?, args ) in + if let inputArray = value as? [Any?] { + let type = args.first as? String ?? "Int" + return inputArray.isEmpty ? "[\(type)]()" : inputArray + } + let type = args.first as? String ?? "Int" + return "[\(type)]()" + } + + return Environment(extensions: [ext]) + } + +} diff --git a/generator/Sources/Generator/generator-help.swift b/generator/Sources/Generator/generator-help.swift deleted file mode 100644 index 0c3aa21d8..000000000 --- a/generator/Sources/Generator/generator-help.swift +++ /dev/null @@ -1,119 +0,0 @@ -import Foundation -import TOMLKit -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -class GeneratorHelp { - var exercise: String - var filePath: String - var json: [String: Any] = [:] - var uuids: [String] = [] - - init(exercise: String, filePath: String) { - self.exercise = exercise - self.filePath = filePath - } - - func getRemoteFiles() throws { - var url: URL - do { - url = URL( - string: - "https://raw.githubusercontent.com/exercism/problem-specifications/master/exercises/\(exercise)/canonical-data.json" - )! - } catch { - throw GeneratorError.remoteError("No remote file found") - } - let data = try String(contentsOf: url, encoding: .utf8) - let fileData = Data(data.utf8) - json = try JSONSerialization.jsonObject(with: fileData, options: []) as? [String: Any] ?? [:] - } - - func toml() throws { - var toml = "" - do { - toml = - try NSString( - contentsOfFile: "\(filePath)/.meta/tests.toml", - encoding: String.Encoding.ascii.rawValue) as String - } catch { - throw GeneratorError.noFile("No toml file found") - } - let table = try TOMLTable(string: toml) - for (key, value) in table { - uuids.append(key) - let table = value.table ?? [:] - for (key, value) in table { - if key.debugDescription == "include" && value.debugDescription == "false" { - uuids.removeLast() - } - } - } - } - - func checkConfig() throws -> Bool { - var fileData: Data - var fileInfo: String - do { - fileInfo = - try NSString( - contentsOfFile: "./config.json", - encoding: String.Encoding.ascii.rawValue) as String - fileData = Data(fileInfo.utf8) - } catch { - throw GeneratorError.noFile("Track config file not found") - } - if let json = try JSONSerialization.jsonObject(with: fileData, options: []) as? [String: Any] { - if let exercises = json["exercises"] as? [String: Any] { - if let practice = exercises["practice"] as? [[String: Any]] { - for exercise in practice { - if let slug = exercise["slug"] as? String { - if slug == self.exercise { - return true - } - } - } - } - } - } - throw GeneratorError.noExercise("Exercise not found") - } - - func removeTestCases() throws { - var cases: [[String: Any]] = [] - if let tests = json["cases"] as? [[String: Any]] { - for test in tests { - if let uuid = test["uuid"] as? String { - if uuids.contains(uuid) { - cases.append(test) - } - } - if let subCases = test["cases"] as? [[String: Any]] { - var subCases2: [[String: Any]] = [] - for subCase in subCases { - if let uuid = subCase["uuid"] as? String { - if uuids.contains(uuid) { - subCases2.append(subCase) - } - } - if let subSubCases = subCase["cases"] as? [[String: Any]] { - var subSubCases2: [[String: Any]] = [] - for subSubCase in subSubCases { - if let uuid = subSubCase["uuid"] as? String { - if uuids.contains(uuid) { - subSubCases2.append(subSubCase) - } - } - } - subCases2.append(["cases": subSubCases2]) - } - - } - cases.append(["cases": subCases2]) - } - } - } - json["cases"] = cases - } -} diff --git a/generator/Sources/Generator/generator-plugins.swift b/generator/Sources/Generator/generator-plugins.swift deleted file mode 100644 index c108ae365..000000000 --- a/generator/Sources/Generator/generator-plugins.swift +++ /dev/null @@ -1,299 +0,0 @@ -import Foundation -import Stencil - -class GeneratorPlugins { - - static let escapeChars = ["\t": "\\t", "\n": "\\n", "\r": "\\r", "\\": "\\\\", "\"": "\\\""] - - func getPlugins() -> Environment { - let ext = Extension() - - ext.registerFilter("isNull") { (value: Any?) in - return NSNull().isEqual(value) - } - - ext.registerFilter("jsonString") { (value: Any?) in - guard let value = value as? [String: Any] else { return nil } - let json = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) - guard let jsonString = String(data: json, encoding: .utf8) else { return nil } - return jsonString.map { Self.escapeChars[String($0)] ?? String($0) }.joined() - } - - ext.registerFilter("camelCase") { (value: Any?) in - if let inputString = value as? String { - let charactersToRemove: [Character] = [ - ",", "'", "?", "!", ".", "=", "+", "&", "%", "$", "#", "@", "(", ")", "[", "]", "{", "}", - "<", ">", "/", "|", ":", ";", - ] - let filteredString = inputString.filter { !charactersToRemove.contains($0) } - let components = filteredString.components(separatedBy: CharacterSet(charactersIn: " -")) - let capitalizedComponents = components.map { $0.capitalized } - let camelCaseString = capitalizedComponents.joined() - return camelCaseString - } - return nil - } - - ext.registerFilter("contains") { (value: Any?, args: [Any?]) in - if let inputString = value as? String { - if let substring = args.first as? String { - return inputString.contains(substring) - } - } else if let inputArray = value as? [String] { - if let string = args.first as? String { - return inputArray.contains(string) - } - } else if let inputArray = value as? [Int] { - if let number = args.first as? Int { - return inputArray.contains(number) - } - } else if let inputDict = value as? [String: Any] { - if let string = args.first as? String { - return inputDict.keys.contains(string) - } - } - return false - } - - ext.registerFilter("any") { (value: Any?, args: [Any?]) in - if let inputArray = value as? [Int] { - if let rule = args.first as? String { - switch rule { - case "isEven": - return inputArray.contains { $0 % 2 == 0 } - case "isOdd": - return inputArray.contains { $0 % 2 != 0 } - case "isNegative": - return inputArray.contains { $0 < 0 } - case "isPositive": - return inputArray.contains { $0 > 0 } - default: - return false - } - } - } - return false - } - - ext.registerFilter("toStringArray") { (value: Any?) in - if let inputString = value as? [String] { - guard !inputString.isEmpty else { return "[]" } - return "[\"\(inputString.joined(separator: "\", \""))\"]" - } else if let array = value as? [Any?] { - return array - } - - return nil - } - - ext.registerFilter("toStringDictionary") { (value: Any?) in - if let inputDictionary = value as? [String: String] { - guard !inputDictionary.isEmpty else { return "[:]" } - var output = "[" - for (key, value) in inputDictionary.sorted(by: { $0.0 < $1.0 }) { - output += "\"\(key)\": \"\(value)\", " - } - output.removeLast(2) - output += "]" - return output - } - if let inputDictionary = value as? [String: Int] { - guard !inputDictionary.isEmpty else { return "[:]" } - var output = "[" - for (key, value) in inputDictionary.sorted(by: { $0.0 < $1.0 }) { - output += "\"\(key)\": \(value), " - } - output.removeLast(2) - output += "]" - return output - } - if let inputDictionary = value as? [String: [String]] { - guard !inputDictionary.isEmpty else { return "[:]" } - var output = "[" - for (key, value) in inputDictionary.sorted(by: { $0.0 < $1.0 }) { - output += "\"\(key)\": [\"\(value.joined(separator: "\", \""))\"], " - } - output.removeLast(2) - output += "]" - return output - } - return nil - } - - ext.registerFilter("inspect") { (value: Any?) in - if let inputString = value as? String { - return inputString.map { Self.escapeChars[String($0)] ?? String($0) }.joined() - } - return nil - } - - ext.registerFilter("minus") { (value: Any?, args: [Any?]) in - if let inputNumber = value as? Int { - if let number = args.first as? Int { - return inputNumber - number - } - } - return nil - } - - ext.registerFilter("toTupleArray") { (value: Any?) in - if let inputArray = value as? [[String]] { - guard !inputArray.isEmpty else { return "[]" } - var output = "[" - for array in inputArray { - output += "(\(array.map { "\"\($0)\"" }.joined(separator: ", "))), " - } - output.removeLast(2) - output += "]" - return output - } - if let inputArray = value as? [[Int]] { - guard !inputArray.isEmpty else { return "[]" } - var output = "[" - for array in inputArray { - output += "(\(array.map { "\($0)" }.joined(separator: ", "))), " - } - output.removeLast(2) - output += "]" - return output - } - - return nil - } - - ext.registerFilter("extractCountKey") { (value: Any?) in - if let inputDictionary = value as? [String: Any] { - if let count = inputDictionary["count"] as? Int { - return count - } - } - return nil - } - - ext.registerFilter("toNilArray") { (value: Any?) in - func replaceValuesWithNil(_ array: [Any?]) -> [Any?] { - var result = [Any?]() - for element in array { - if let nestedArray = element as? [Any?] { - result.append(replaceValuesWithNil(nestedArray)) - } else { - if NSNull().isEqual(element) { - result.append(nil) - } else { - result.append(element) - } - - } - } - - return result - } - if let inputArray = value as? [Any?] { - return replaceValuesWithNil(inputArray) - } - - return nil - } - - ext.registerFilter("length") { (value: Any?) in - if let inputString = value as? String { - return inputString.count - } - if let inputArray = value as? [Any?] { - return inputArray.count - } - if let inputDictionary = value as? [String: Any?] { - return inputDictionary.count - } - return nil - } - - ext.registerFilter("toEnumArray") { (value: Any?) in - if let inputArray = value as? [String] { - guard !inputArray.isEmpty else { return "[]" } - var output = "[" - for element in inputArray { - output += ".\(element), " - } - output.removeLast(2) - output += "]" - return output - } - return nil - } - - ext.registerFilter("strain") { (value: Any?) in - if let input = value as? String { - if input.contains("starts_with") { - return "{x in x.starts(with: \"z\")}" - } else if input.contains("contains") { - return "{x in x.contains(5)}" - } - let trimmedInput = input.replacingOccurrences(of: "fn(x) -> ", with: "") - return "{x in \(trimmedInput)}" - } - return [] - } - - ext.registerFilter("round") { (value: Any?, args: [Any?]) in - if let inputNumber = value as? Int { - return inputNumber - } - if let inputNumber = value as? Double { - if let precision = args.first as? Int { - let divisor = pow(10.0, Double(precision)) - return (inputNumber * divisor).rounded() / divisor - } - } - return nil - } - - ext.registerFilter("knapsackItem") { (value: Any?) in - if let item = value as? [String: Any] { - let itemWeight = String(describing: item["weight", default: ""]) - let itemValue = String(describing: item["value", default: ""]) - return "Item(weight:\(itemWeight), value:\(itemValue))" - } - return "// Something else ..." - } - - ext.registerFilter("complexNumber") { (value: Any?) in - if let input = value as? String { - switch input { - case "pi": - return "Double.pi" - case "e": - return "exp(1)" - case "ln(2)": - return "log(2)" - case "ln(2)/2": - return "log(2)/2" - case "pi/4": - return "Double.pi/4" - default: - return input - } - } - return value - } - ext.registerFilter("listOps") { (value: Any?) in - if let inputString = value as? String { - return inputString.replacingOccurrences(of: "foldl", with: "foldLeft") - .replacingOccurrences(of: "foldr", with: "foldRight") - } - return nil - } - - ext.registerFilter("defaultArray") { (value: Any?, args ) in - if let inputArray = value as? [Any?] { - let type = args.first as? String ?? "Int" - return inputArray.isEmpty ? "[\(type)]()" : inputArray - } - let type = args.first as? String ?? "Int" - return "[\(type)]()" - } - - let environment = Environment(extensions: [ext]) - return environment - } -} diff --git a/generator/Sources/Generator/main.swift b/generator/Sources/Generator/main.swift deleted file mode 100644 index 3134ba795..000000000 --- a/generator/Sources/Generator/main.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Foundation -import Stencil -import SwiftFormat - -enum GeneratorError: Error { - case invalidArgumentCount - case noDirectory(String) - case noFile(String) - case noExercise(String) - case remoteError(String) -} - -class Generator { - var generatorHelp: GeneratorHelp - var filePath: String - var exercise: String - - init(exercise: String, filePath: String = "") { - var filePath = filePath - if filePath.isEmpty { - filePath = "./exercises/practice/\(exercise)" - } - self.generatorHelp = GeneratorHelp(exercise: exercise, filePath: filePath) - self.filePath = filePath - self.exercise = exercise - } - - func run() throws { - try generatorHelp.checkConfig() - try generatorHelp.toml() - try generatorHelp.getRemoteFiles() - try generatorHelp.removeTestCases() - guard directoryExists(atPath: "\(filePath)/Tests") else { - throw GeneratorError.noDirectory("Tests directory not found: \(filePath)") - } - var template = try loadTemplate() - let environment = GeneratorPlugins().getPlugins() - template = try environment.renderTemplate(string: template, context: generatorHelp.json) - let path = try getTestPath() - - var text = "" - let configuration = Configuration() - let swiftFormat = SwiftFormatter(configuration: configuration) - try swiftFormat.format(source: template, assumingFileURL: nil, selection: .infinite, to: &text) - try text.write(toFile: path, atomically: true, encoding: .utf8) - } - - func loadTemplate() throws -> String { - var fileInfo: String - do { - fileInfo = - try NSString( - contentsOfFile: "\(filePath)/.meta/template.swift", - encoding: String.Encoding.ascii.rawValue) as String - } catch { - throw GeneratorError.noFile("No template file found") - } - return fileInfo - } - - private func directoryExists(atPath path: String) -> Bool { - var isDirectory: ObjCBool = true - let fileManager = FileManager.default - let exists = fileManager.fileExists(atPath: path, isDirectory: &isDirectory) - return exists && isDirectory.boolValue - } - - private func getTestPath() throws -> String { - var fileData: Data - var fileInfo: String - do { - fileInfo = - try NSString( - contentsOfFile: "\(filePath)/.meta/config.json", - encoding: String.Encoding.ascii.rawValue) as String - fileData = Data(fileInfo.utf8) - } catch { - throw GeneratorError.noFile("No Exercise config file was found") - } - if let json = try JSONSerialization.jsonObject(with: fileData, options: []) as? [String: Any] { - if let files = json["files"] as? [String: Any] { - if let tests = files["test"] as? [String] { - return "\(filePath)/\(tests[0])" - } - } - } - throw GeneratorError.noFile("Tests file not found") - } -} - -if CommandLine.arguments.count == 3 { - try Generator(exercise: CommandLine.arguments[1], filePath: CommandLine.arguments[2]).run() -} else if CommandLine.arguments.count == 2 { - try Generator(exercise: CommandLine.arguments[1]).run() -} else { - throw GeneratorError.invalidArgumentCount -} diff --git a/generator/Tests/GeneratorTests/CanonicalDataTests.swift b/generator/Tests/GeneratorTests/CanonicalDataTests.swift new file mode 100644 index 000000000..13f16bb9d --- /dev/null +++ b/generator/Tests/GeneratorTests/CanonicalDataTests.swift @@ -0,0 +1,163 @@ +import Testing +import Foundation +@testable import Generator + +@Test +func `Track canonical data all tests are kept`() throws { + let expectedUUIDs = Set(["a", "b", "c", "d"]) + let expectedData = try CanonicalData(from: "valid_config") + let data = try CanonicalData(from: "valid_config") + + #expect(data.uuidSet == expectedUUIDs) + #expect(data == expectedData) +} + +@Test +func `Track canonical data all tests are kept after whitelisting`() throws { + let expectedUUIDs = Set(["a", "b", "c", "d"]) + let expectedData = try CanonicalData(from: "valid_config") + var data = try CanonicalData(from: "valid_config") + + data.whitelistTests(withUUIDs: expectedUUIDs) + + #expect(data.uuidSet == expectedUUIDs) + #expect(data == expectedData) +} + +@Test +func `Track canonical data some tests are filtered`() throws { + let expectedUUIDs = Set(["b", "c"]) + let expectedData = try CanonicalData(from: "valid_config_whitelisted") + var data = try CanonicalData(from: "valid_config") + + data.whitelistTests(withUUIDs: expectedUUIDs) + + #expect(data.uuidSet == expectedUUIDs) + #expect(data == expectedData) +} + +@Test +func `Track canonical data all tests are filtered`() throws { + let expectedUUIDs = Set() + let expectedData = try CanonicalData(from: "valid_config_empty") + var data = try CanonicalData(from: "valid_config") + + data.whitelistTests(withUUIDs: expectedUUIDs) + + #expect(data.uuidSet == expectedUUIDs) + #expect(data == expectedData) +} + +@Test +func `Track canonical data all tests are filtered with missing keys`() throws { + let expectedUUIDs = Set() + let expectedData = try CanonicalData(from: "valid_config_empty") + var data = try CanonicalData(from: "valid_config") + + data.whitelistTests(withUUIDs: ["e", "f", "g", "e"]) + + #expect(data.uuidSet == expectedUUIDs) + #expect(data == expectedData) +} + +@Test +func `Track canonical data all tests are kept in nested`() throws { + let expectedUUIDs = Set(["first", "second", "third", "fourth", "fiths", "sixth"]) + var data = try CanonicalData(from: "valid_nested") + + data.whitelistTests(withUUIDs: expectedUUIDs) + + #expect(data.uuidSet == expectedUUIDs) +} + +@Test +func `Track canonical data some tests are kept in nested`() throws { + let expectedUUIDs = Set(["first", "third", "fiths"]) + var data = try CanonicalData(from: "valid_nested") + + data.whitelistTests(withUUIDs: expectedUUIDs) + + #expect(data.uuidSet == expectedUUIDs) +} + +// MARK: - Helpers + +extension CanonicalData { + + fileprivate init(from fileName: String) throws { + let url = try Bundle.module.urlForResource(fileName) + let data = try Data(contentsOf: url) + let jsonData = try JSONSerialization.jsonObject(with: data) + + guard let jsonDictionary = jsonData as? [String: Any] else { + #expect(Bool(false), "Expected json data to be of type [String: Any].") + self.init(dictionary: [:]) + return + } + + self.init(dictionary: jsonDictionary) + } + + fileprivate var uuidSet: Set { + var uuids = Set() + if let cases = context["cases"] as? [[String: Any]] { + uuids.formUnion(collectUUIDs(from: cases)) + } + return uuids + } + + private func collectUUIDs(from cases: [[String: Any]]) -> Set { + var uuids = Set() + for caseData in cases { + if let uuid = caseData["uuid"] as? String { + uuids.insert(uuid) + } + else if let nestedCases = caseData["cases"] as? [[String: Any]] { + uuids.formUnion(collectUUIDs(from: nestedCases)) + } + } + return uuids + } + +} + +extension CanonicalData: Equatable { + + public static func == (lhs: Self, rhs: Self) -> Bool { deepEqual(lhs.context, rhs.context) } + + private static func deepEqual(_ lhs: Any?, _ rhs: Any?) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + return true + + case let (l as [String: Any?], r as [String: Any?]): + guard Set(l.keys) == Set(r.keys) else { return false } + return l.allSatisfy { key, value in deepEqual(value, r[key] ?? nil) } + + case let (l as [Any], r as [Any]): + guard l.count == r.count else { return false } + return zip(l, r).allSatisfy { deepEqual($0, $1) } + + case let (l as NSNumber, r as NSNumber): + return l == r + + case let (l as Bool, r as Bool): + return l == r + + case let (l as String, r as String): + return l == r + + default: + return false + } + } + +} + +extension Bundle { + + fileprivate func urlForResource(_ name: String) throws -> URL { + return try urlForResource(name, fileExtension: "json", subdirectory: "Resources/CanonicalData") + } + +} diff --git a/generator/Tests/GeneratorTests/ExerciseConfigTests.swift b/generator/Tests/GeneratorTests/ExerciseConfigTests.swift new file mode 100644 index 000000000..219ead577 --- /dev/null +++ b/generator/Tests/GeneratorTests/ExerciseConfigTests.swift @@ -0,0 +1,107 @@ +import Testing +import Foundation +@testable import Generator + +@Test +func `Testing exercise config file not found`() { + let error = #expect(throws: CocoaError.self) { + _ = try ExerciseConfig(from: URL(filePath: "config.json")) + } + #expect(error?.code == .fileReadNoSuchFile) +} + +@Test +func `Testing exercise config file has valid structure`() { + #expect(throws: Never.self) { + _ = try ExerciseConfig(from: """ + { + "files": { + "solution": [], + "test": [ + "Tests/AcronymTests/AcronymTests.swift" + ], + "example": [] + } + } + """) + } +} + +@Test +func `Testing exercise config file has invalid JSON structure`() { + #expect(throws: DecodingError.self) { + _ = try ExerciseConfig(from: """ + { + "files": { + "solution": [], + "example": [] + } + } + """) + } +} + +@Test +func `Testing exercise config has 0 tests`() { + let error = #expect(throws: GeneratorError.self) { + let config = try ExerciseConfig(from: """ + { + "files": { + "solution": [], + "test": [], + "example": [] + } + } + """) + _ = try config.getTargetTestFileURL() + } + guard case .internalError = error else { + #expect(Bool(false), "Expected to return internal error") + return + } +} + +@Test +func `Testing exercise config contains test file`() { + #expect(throws: Never.self) { + let config = try ExerciseConfig(from: """ + { + "files": { + "solution": [], + "test": ["Tests/AcronymTests/AcronymTests.swift"], + "example": [] + } + } + """) + let path = try config.getTargetTestFileURL() + #expect(path == "Tests/AcronymTests/AcronymTests.swift") + } +} + +@Test +func `Testing exercise config contains multiple test files`() { + #expect(throws: Never.self) { + let config = try ExerciseConfig(from: """ + { + "files": { + "solution": [], + "test": ["Tests/AcronymTests/AcronymTests1.swift", "Tests/AcronymTests/AcronymTests2.swift"], + "example": [] + } + } + """) + let path = try config.getTargetTestFileURL() + #expect(path == "Tests/AcronymTests/AcronymTests1.swift") + } +} + +@Test +func `Testing exercise config loads from fixture file`() throws { + let expectedTestFilePath = "Tests/AcronymTests/AcronymTests.swift" + let url = try Bundle.module.urlForResource("valid_config", fileExtension: "json", subdirectory: "Resources/ExerciseConfig") + #expect(throws: Never.self) { + let config = try ExerciseConfig(from: url) + #expect(config.files.test.count == 1) + #expect(expectedTestFilePath == config.files.test.first) + } +} diff --git a/generator/Tests/GeneratorTests/GeneratorTests.swift b/generator/Tests/GeneratorTests/GeneratorTests.swift deleted file mode 100644 index 26da747c0..000000000 --- a/generator/Tests/GeneratorTests/GeneratorTests.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Testing -@testable import Generator - -@Test("Testing having all tests be included") -func testAllTestsIncluded() { - let generatorHelp = GeneratorHelp(exercise: "two-fer", filePath: "./Tests/GeneratorTests/files/all-tests-included") - #expect(throws: Never.self) {try generatorHelp.toml()} - let expected = ["1cf3e15a-a3d7-4a87-aeb3-ba1b43bc8dce", "3549048d-1a6e-4653-9a79-b0bda163e8d5", "b4c6dbb8-b4fb-42c2-bafd-10785abe7709"] - #expect(generatorHelp.uuids == expected) -} - -@Test("Testing having all tests be removed") -func testAllTestsRemoved() { - let generatorHelp = GeneratorHelp(exercise: "two-fer", filePath: "./Tests/GeneratorTests/files/all-test-removed") - #expect(throws: Never.self) {try generatorHelp.toml()} - let expected: [String] = [] - #expect(generatorHelp.uuids == expected) -} - -@Test("Testing having two tests be removed") -func testTwoTestsRemoved() { - let generatorHelp = GeneratorHelp(exercise: "two-fer", filePath: "./Tests/GeneratorTests/files/two-tests-removed") - #expect(throws: Never.self) {try generatorHelp.toml()} - let expected = ["b4c6dbb8-b4fb-42c2-bafd-10785abe7709"] - #expect(generatorHelp.uuids == expected) -} diff --git a/generator/Tests/GeneratorTests/Helpers/Bundle+Resource.swift b/generator/Tests/GeneratorTests/Helpers/Bundle+Resource.swift new file mode 100644 index 000000000..8577707c1 --- /dev/null +++ b/generator/Tests/GeneratorTests/Helpers/Bundle+Resource.swift @@ -0,0 +1,23 @@ +import Foundation + +enum ResourceError: Error, CustomStringConvertible { + case missingResource(name: String, subdirectory: String?) + + var description: String { + switch self { + case let .missingResource(name, subdirectory): + return "Missing resource: \(name) \(subdirectory == nil ? "" : "in \(subdirectory!)")" + } + } +} + +extension Bundle { + + func urlForResource(_ name: String, fileExtension: String, subdirectory: String? = nil) throws -> URL { + guard let url = url(forResource: name, withExtension: fileExtension, subdirectory: subdirectory) else { + throw ResourceError.missingResource(name: name, subdirectory: subdirectory) + } + return url + } + +} diff --git a/generator/Tests/GeneratorTests/Resources/CanonicalData/valid_config.json b/generator/Tests/GeneratorTests/Resources/CanonicalData/valid_config.json new file mode 100644 index 000000000..d815573b0 --- /dev/null +++ b/generator/Tests/GeneratorTests/Resources/CanonicalData/valid_config.json @@ -0,0 +1,59 @@ +{ + "exercise": "example", + "comments": [ "Some comment section" ], + "cases": [ + { + "description": "append entries to a list and return the new list", + "cases": [ + { + "uuid": "a", + "description": "empty lists", + "property": "append", + "input": { + "list1": [], + "list2": [] + }, + "expected": [] + }, + { + "uuid": "b", + "description": "list to empty list", + "property": "append", + "input": { + "list1": [], + "list2": [1, 2, 3, 4] + }, + "expected": [1, 2, 3, 4] + } + ] + }, + { + "description": "folds (reduces) the given list from the right with a function", + "comments": [ "For function: acc is the accumulator and el is the current element "], + "cases": [ + { + "uuid": "c", + "description": "empty list", + "property": "foldr", + "input": { + "list": [], + "initial": 2, + "function": "(x, y) -> x * y" + }, + "expected": 2 + }, + { + "uuid": "d", + "description": "direction independent function applied to non-empty list", + "property": "foldr", + "input": { + "list": [1, 2, 3, 4], + "initial": 5, + "function": "(x, y) -> x + y" + }, + "expected": 15 + } + ] + } + ] +} diff --git a/generator/Tests/GeneratorTests/Resources/CanonicalData/valid_config_empty.json b/generator/Tests/GeneratorTests/Resources/CanonicalData/valid_config_empty.json new file mode 100644 index 000000000..2617ab410 --- /dev/null +++ b/generator/Tests/GeneratorTests/Resources/CanonicalData/valid_config_empty.json @@ -0,0 +1,5 @@ +{ + "exercise": "example", + "comments": [ "Some comment section" ], + "cases": [] +} diff --git a/generator/Tests/GeneratorTests/Resources/CanonicalData/valid_config_whitelisted.json b/generator/Tests/GeneratorTests/Resources/CanonicalData/valid_config_whitelisted.json new file mode 100644 index 000000000..8cca846c0 --- /dev/null +++ b/generator/Tests/GeneratorTests/Resources/CanonicalData/valid_config_whitelisted.json @@ -0,0 +1,38 @@ +{ + "exercise": "example", + "comments": [ "Some comment section" ], + "cases": [ + { + "description": "append entries to a list and return the new list", + "cases": [ + { + "uuid": "b", + "description": "list to empty list", + "property": "append", + "input": { + "list1": [], + "list2": [1, 2, 3, 4] + }, + "expected": [1, 2, 3, 4] + } + ] + }, + { + "description": "folds (reduces) the given list from the right with a function", + "comments": [ "For function: acc is the accumulator and el is the current element "], + "cases": [ + { + "uuid": "c", + "description": "empty list", + "property": "foldr", + "input": { + "list": [], + "initial": 2, + "function": "(x, y) -> x * y" + }, + "expected": 2 + } + ] + } + ] +} diff --git a/generator/Tests/GeneratorTests/Resources/CanonicalData/valid_nested.json b/generator/Tests/GeneratorTests/Resources/CanonicalData/valid_nested.json new file mode 100644 index 000000000..630f4b4d2 --- /dev/null +++ b/generator/Tests/GeneratorTests/Resources/CanonicalData/valid_nested.json @@ -0,0 +1,31 @@ +{ + "exercise": "acronym", + "cases": [ + { "uuid": "first" }, + { + "cases": [ + { "uuid": "second" }, + { + "cases": [ + { "uuid": "third" }, + { + "cases": [ + { "uuid": "fourth" }, + { + "cases": [ + { "uuid": "fiths" }, + { + "cases": [ + { "uuid": "sixth" } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/generator/Tests/GeneratorTests/Resources/ExerciseConfig/valid_config.json b/generator/Tests/GeneratorTests/Resources/ExerciseConfig/valid_config.json new file mode 100644 index 000000000..c718c9c0f --- /dev/null +++ b/generator/Tests/GeneratorTests/Resources/ExerciseConfig/valid_config.json @@ -0,0 +1,22 @@ +{ + "authors": [ + "masters3d" + ], + "contributors": [ + "bhargavg" + ], + "files": { + "solution": [ + "Sources/Acronym/Acronym.swift" + ], + "test": [ + "Tests/AcronymTests/AcronymTests.swift" + ], + "example": [ + ".meta/Sources/Acronym/AcronymExample.swift" + ] + }, + "blurb": "Convert a long phrase to its acronym.", + "source": "Julien Vanier", + "source_url": "https://github.com/monkbroc" +} diff --git a/generator/Tests/GeneratorTests/files/all-tests-included/.meta/tests.toml b/generator/Tests/GeneratorTests/Resources/TomlFile/valid_toml.toml similarity index 100% rename from generator/Tests/GeneratorTests/files/all-tests-included/.meta/tests.toml rename to generator/Tests/GeneratorTests/Resources/TomlFile/valid_toml.toml diff --git a/generator/Tests/GeneratorTests/Resources/TrackConfig/valid_config.json b/generator/Tests/GeneratorTests/Resources/TrackConfig/valid_config.json new file mode 100644 index 000000000..555dedcac --- /dev/null +++ b/generator/Tests/GeneratorTests/Resources/TrackConfig/valid_config.json @@ -0,0 +1,99 @@ +{ + "language": "Swift", + "slug": "swift", + "active": true, + "status": { + "concept_exercises": true, + "test_runner": true, + "representer": false, + "analyzer": false + }, + "blurb": "Swift is a modern open-source language with the aim of making programming simple things easy, and difficult things possible while still being fast and safe by design. Swift can run in scripting mode in the included REPL or it can be compiled with full support for C and Objective-C interoperability.", + "version": 3, + "online_editor": { + "indent_style": "space", + "indent_size": 2, + "highlightjs_language": "swift" + }, + "test_runner": { + "average_run_time": 9 + }, + "files": { + "solution": [ + "Sources/%{pascal_slug}/%{pascal_slug}.swift" + ], + "test": [ + "Tests/%{pascal_slug}Tests/%{pascal_slug}Tests.swift" + ], + "example": [ + ".meta/Sources/%{pascal_slug}/%{pascal_slug}Example.swift" + ], + "exemplar": [ + ".meta/Sources/%{pascal_slug}/%{pascal_slug}Exemplar.swift" + ] + }, + "exercises": { + "concept": [ + { + "slug": "lasagna", + "name": "Layers of Lasagna", + "uuid": "44ae5892-40a5-4483-a181-28e1c38645d6", + "concepts": [ + "basics" + ], + "prerequisites": [], + "status": "active" + }, + { + "slug": "wings-quest", + "name": "Wings Quest", + "uuid": "13a703ee-2180-49c6-86c7-7c848d636a3c", + "concepts": [ + "booleans" + ], + "prerequisites": [ + "basics" + ], + "status": "beta" + } + ], + "practice": [ + { + "slug": "difference-of-squares", + "name": "Difference of Squares", + "uuid": "ef114733-886b-4d4b-a713-3ba169a85025", + "practices": [], + "prerequisites": [], + "difficulty": 1, + "topics": [ + "integers", + "math" + ] + }, + { + "slug": "gigasecond", + "name": "Gigasecond", + "uuid": "0a1ae85a-1d89-453a-8b97-303181d7874d", + "practices": [], + "prerequisites": [], + "difficulty": 1, + "topics": [ + "dates" + ] + } + ] + }, + "concepts": [ + { + "uuid": "ca0161f2-1915-4911-8595-3854c391a502", + "slug": "arrays", + "name": "Arrays" + }, + { + "uuid": "e59edeed-a13b-427b-9677-c36f276f440a", + "slug": "basics", + "name": "Basics" + } + ], + "tags": [] +} diff --git a/generator/Tests/GeneratorTests/TomlFileTests.swift b/generator/Tests/GeneratorTests/TomlFileTests.swift new file mode 100644 index 000000000..55b7836a7 --- /dev/null +++ b/generator/Tests/GeneratorTests/TomlFileTests.swift @@ -0,0 +1,104 @@ +import Testing +import Foundation +import TOMLKit +@testable import Generator + +@Test +func `Testing TOML file not found`() { + let error = #expect(throws: CocoaError.self) { + _ = try TOMLConfig(from: URL(filePath: "file.toml")) + } + #expect(error?.code == .fileReadNoSuchFile) +} + +@Test +func `Testing TOML file is empty`() { + #expect(throws: Never.self) { + let file = try TOMLConfig(from: "") + #expect(file.uuids.isEmpty) + } +} + +@Test +func `Testing Malformed TOML files are prohibited`() { + #expect(throws: TOMLParseError.self) { + let file = try TOMLConfig(from: """ + [] + """) + #expect(file.uuids.isEmpty) + } +} + +@Test +func `Testing a single uuid to be included in TOML`() { + let expectedUUIDs = Set(["abc"]) + #expect(throws: Never.self) { + let file = try TOMLConfig(from: "[abc]") + #expect(file.uuids == expectedUUIDs) + } +} + +@Test +func `Testing all tests to be included in TOML`() { + let expectedUUIDs = Set([ + "1cf3e15a-a3d7-4a87-aeb3-ba1b43bc8dce", + "b4c6dbb8-b4fb-42c2-bafd-10785abe7709" + ]) + + #expect(throws: Never.self) { + let file = try TOMLConfig(from: """ + [1cf3e15a-a3d7-4a87-aeb3-ba1b43bc8dce] + description = "no name given" + + [b4c6dbb8-b4fb-42c2-bafd-10785abe7709] + description = "a name given" + """) + #expect(file.uuids == expectedUUIDs) + } +} + +@Test +func `Testing no tests to be included in TOML`() { + #expect(throws: Never.self) { + let file = try TOMLConfig(from: """ + [1cf3e15a-a3d7-4a87-aeb3-ba1b43bc8dce] + description = "no name given" + include = false + """) + #expect(file.uuids.isEmpty) + } +} + +@Test +func `Testing some tests to be included in TOML`() { + let expectedUUIDs = Set([ + "1cf3e15a-a3d7-4a87-aeb3-ba1b43bc8dce" + ]) + + #expect(throws: Never.self) { + let file = try TOMLConfig(from: """ + [3549048d-1a6e-4653-9a79-b0bda163e8d5] + description = "no name given" + include = false + + [1cf3e15a-a3d7-4a87-aeb3-ba1b43bc8dce] + description = "a name given" + """) + #expect(file.uuids == expectedUUIDs) + } +} + +@Test +func `Testing TOML config loads from fixture file`() throws { + let expectedUUIDs = Set([ + "1cf3e15a-a3d7-4a87-aeb3-ba1b43bc8dce", + "b4c6dbb8-b4fb-42c2-bafd-10785abe7709", + "3549048d-1a6e-4653-9a79-b0bda163e8d5" + ]) + + let url = try Bundle.module.urlForResource("valid_toml", fileExtension: "toml", subdirectory: "Resources/TomlFile") + #expect(throws: Never.self) { + let config = try TOMLConfig(from: url) + #expect(expectedUUIDs == config.uuids) + } +} diff --git a/generator/Tests/GeneratorTests/TrackConfigTests.swift b/generator/Tests/GeneratorTests/TrackConfigTests.swift new file mode 100644 index 000000000..57888d3a2 --- /dev/null +++ b/generator/Tests/GeneratorTests/TrackConfigTests.swift @@ -0,0 +1,107 @@ +import Testing +import Foundation +@testable import Generator + +@Test +func `Testing track config file not found`() { + let error = #expect(throws: CocoaError.self) { + _ = try TrackConfig(from: URL(filePath: "config.json")) + } + #expect(error?.code == .fileReadNoSuchFile) +} + +@Test +func `Testing track config file has valid structure`() { + #expect(throws: Never.self) { + _ = try TrackConfig(from: testConfig) + } +} + +@Test +func `Testing track config file has invalid JSON structure`() { + #expect(throws: DecodingError.self) { + _ = try TrackConfig(from: "{ invalid-json }") + } +} + +@Test +func `Testing track config handles empty exercises arrays`() { + #expect(throws: Never.self) { + let config = try TrackConfig(from: "{\"exercises\": {\"concept\": [],\"practice\": []}}") + #expect(config.checkExistance(slug: "nonexistent", kind: .concept) == false) + #expect(config.checkExistance(slug: "nonexistent", kind: .practice) == false) + } +} + +@Test +func `Testing track config contains concept exercise`() { + #expect(throws: Never.self) { + let config = try TrackConfig(from: testConfig) + #expect(config.checkExistance(slug: "wings-quest", kind: .concept)) + } +} + +@Test +func `Testing track config does not contain concept exercise`() { + #expect(throws: Never.self) { + let config = try TrackConfig(from: testConfig) + #expect(config.checkExistance(slug: "difference-of-squares", kind: .concept) == false) + } +} + +@Test +func `Testing track config contains practice exercise`() { + #expect(throws: Never.self) { + let config = try TrackConfig(from: testConfig) + #expect(config.checkExistance(slug: "gigasecond", kind: .practice)) + } +} + +@Test +func `Testing track config does not contains practice exercise`() { + #expect(throws: Never.self) { + let config = try TrackConfig(from: testConfig) + #expect(config.checkExistance(slug: "lasagna", kind: .practice) == false) + } +} + +@Test +func `Testing track config loads from fixture file`() throws { + let url = try Bundle.module.urlForResource("valid_config", fileExtension: "json", subdirectory: "Resources/TrackConfig") + #expect(throws: Never.self) { + let config = try TrackConfig(from: url) + #expect(config.checkExistance(slug: "lasagna", kind: .concept)) + #expect(config.checkExistance(slug: "gigasecond", kind: .practice)) + } +} + +fileprivate let testConfig = """ +{ + "exercises": { + "concept": [ + { + "slug": "lasagna", + "name": "Layers of Lasagna", + "uuid": "44ae5892-40a5-4483-a181-28e1c38645d6", + }, + { + "slug": "wings-quest", + "name": "Wings Quest", + "uuid": "13a703ee-2180-49c6-86c7-7c848d636a3c", + } + ], + "practice": [ + { + "slug": "difference-of-squares", + "name": "Difference of Squares", + "uuid": "ef114733-886b-4d4b-a713-3ba169a85025", + }, + { + "slug": "gigasecond", + "name": "Gigasecond", + "uuid": "0a1ae85a-1d89-453a-8b97-303181d7874d", + } + ] + }, +} +""" diff --git a/generator/Tests/GeneratorTests/files/all-test-removed/.meta/tests.toml b/generator/Tests/GeneratorTests/files/all-test-removed/.meta/tests.toml deleted file mode 100644 index c14aba02a..000000000 --- a/generator/Tests/GeneratorTests/files/all-test-removed/.meta/tests.toml +++ /dev/null @@ -1,23 +0,0 @@ -# This is an auto-generated file. -# -# Regenerating this file via `configlet sync` will: -# - Recreate every `description` key/value pair -# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications -# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion) -# - Preserve any other key/value pair -# -# As user-added comments (using the # character) will be removed when this file -# is regenerated, comments can be added via a `comment` key. - -[1cf3e15a-a3d7-4a87-aeb3-ba1b43bc8dce] -description = "no name given" -include = false - -[b4c6dbb8-b4fb-42c2-bafd-10785abe7709] -description = "a name given" -include = false - -[3549048d-1a6e-4653-9a79-b0bda163e8d5] -description = "another name given" -include = false - diff --git a/generator/Tests/GeneratorTests/files/two-tests-removed/.meta/tests.toml b/generator/Tests/GeneratorTests/files/two-tests-removed/.meta/tests.toml deleted file mode 100644 index fed4485e6..000000000 --- a/generator/Tests/GeneratorTests/files/two-tests-removed/.meta/tests.toml +++ /dev/null @@ -1,21 +0,0 @@ -# This is an auto-generated file. -# -# Regenerating this file via `configlet sync` will: -# - Recreate every `description` key/value pair -# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications -# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion) -# - Preserve any other key/value pair -# -# As user-added comments (using the # character) will be removed when this file -# is regenerated, comments can be added via a `comment` key. - -[1cf3e15a-a3d7-4a87-aeb3-ba1b43bc8dce] -description = "no name given" -include = false - -[b4c6dbb8-b4fb-42c2-bafd-10785abe7709] -description = "a name given" - -[3549048d-1a6e-4653-9a79-b0bda163e8d5] -description = "another name given" -include = false diff --git a/generator/readme.md b/generator/readme.md index 631482388..ec9d0c7ff 100644 --- a/generator/readme.md +++ b/generator/readme.md @@ -5,14 +5,41 @@ The reason for this is that it can be quite tedious to create the test file for ## Get started +### Prerequesites + Before starting you need to have the following in place: - You have a folder called `Test` in the exercise folder. -- You have added the exercises to the `config.json` file. +- You have added the exercise to the track's [`config.json`][track-config-json] file. - The exercise folder has a `config.json` file. - You have added the template for the exercise, which is a file called `template.swift` and found in the `.meta` folder. -Now you can run the generator: +### Running the generator + +```bash +USAGE: Generator [--exercise-kind ] [--exercise-path ] [--track-directory-path ] + +ARGUMENTS: + The slug of the exercise to process. + +OPTIONS: + -e, --exercise-kind + The kind of exercise to process. Possible + values: practice, concept (values: + practice, concept; default: practice) + --exercise-path + The absolute or relative path to the + exercise within the track directory. + Will use exercise kind and track path to + calculate if not specified. + --track-directory-path + The absolute or relative path to the + track directory. Defaults to the current + directory. + -h, --help Show help information. +``` + +You can run the generator using `swift run`, which will compile and run the generator: ```bash # Format: @@ -20,8 +47,27 @@ $ swift run --package-path ./generator Generator # Example: $ swift run --package-path ./generator Generator two-fer + +# Example with exercise kind +$ swift run --package-path ./generator Generator two-fer -e concept ``` +or directly use the binary after compiling it: + +```bash +# Compile and place binary into ./bin folder: +$ swift build -c release --package-path ./generator && cp "$(find ./generator/.build -type f -path '*/release/Generator' 2>/dev/null)" ./bin/ + +# Show usage and available arguments: +$ ./bin/Generator -h + +# Example: +$ ./bin/Generator two-fer +``` + +> [!NOTE] +> Compiling in debug mode is faster, but the generated binary will run slower and be larger since it includes debug information and skips optimization steps. + The generator will automatically format the files for you, so you don't have to worry about that. ## Building templates @@ -72,7 +118,7 @@ The current custom plugins are: - `listOps`: Replaces occurances of "foldr" to "foldRight" - `defaultArray`: Provides default value if the input value is null. -The plugins can be found in the `generator-plugins.swift` file, and can be used like this: +The plugins can be found in the [`Stencil.swift`][stencil-plugins] file, and can be used like this: ```swift {{ "hello world" | camelCase }} @@ -108,7 +154,7 @@ It will compare the uuids from the toml file with the uuids from the json file, After that will the generator read the template file, and then load the template engine with the plugins. The generator will feed the template with the json file, and then render the template and then format the file. -The generator also has an optional 2nd argument, which is the path to the exercise folder, which can be used when not wanting it to run on the normal exercise folders. - +[track-config-json]: https://github.com/exercism/swift/blob/main/config.json [stencil]: https://github.com/stencilproject/Stencil +[stencil-plugins]: https://github.com/exercism/swift/tree/main/generator/Sources/Generator/Stencil.swift [toml]: https://github.com/LebJe/TOMLKit