diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/General-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/General-Package.xcscheme new file mode 100644 index 0000000..7bf6c57 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/General-Package.xcscheme @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/General.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/General.xcscheme new file mode 100644 index 0000000..b244165 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/General.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/GeneralIOs.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/GeneralIOs.xcscheme new file mode 100644 index 0000000..77c7f6b --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/GeneralIOs.xcscheme @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/GeneralKit.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/GeneralKit.xcscheme new file mode 100644 index 0000000..cf20136 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/GeneralKit.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.resolved b/Package.resolved index bdb6027..1115124 100644 --- a/Package.resolved +++ b/Package.resolved @@ -10,6 +10,33 @@ "version": "4.5.0" } }, + { + "package": "Files", + "repositoryURL": "https://github.com/JohnSundell/Files", + "state": { + "branch": null, + "revision": "d273b5b7025d386feef79ef6bad7de762e106eaf", + "version": "4.2.0" + } + }, + { + "package": "PathKit", + "repositoryURL": "https://github.com/kylef/PathKit", + "state": { + "branch": "master", + "revision": "2fcd4618d52869b342e208324d455131a48f9e9b", + "version": null + } + }, + { + "package": "SourceKitten", + "repositoryURL": "https://github.com/jpsim/SourceKitten.git", + "state": { + "branch": null, + "revision": "558628392eb31d37cb251cfe626c53eafd330df6", + "version": "0.31.1" + } + }, { "package": "Spectre", "repositoryURL": "https://github.com/kylef/Spectre.git", @@ -33,7 +60,7 @@ "repositoryURL": "https://github.com/rosberry/StencilSwiftKit.git", "state": { "branch": "stable", - "revision": "2e289f24626ce6bc75e515fd41a52db0b139097d", + "revision": "17d5fb6c67a7a391b36f1154c80c429ac3653ec4", "version": null } }, @@ -46,6 +73,33 @@ "version": "0.5.0" } }, + { + "package": "SwiftParsec", + "repositoryURL": "https://github.com/rosberry/SwiftParsec", + "state": { + "branch": "master", + "revision": "2725b7e9580988be124d86e507921f88a0e5bb3a", + "version": null + } + }, + { + "package": "SWXMLHash", + "repositoryURL": "https://github.com/drmohundro/SWXMLHash.git", + "state": { + "branch": null, + "revision": "9183170d20857753d4f331b0ca63f73c60764bf3", + "version": "5.0.2" + } + }, + { + "package": "umaler", + "repositoryURL": "https://github.com/rosberry/umaler.git", + "state": { + "branch": "master", + "revision": "7a713e838978f7cdee6b40eae3a8efd89cf055d0", + "version": null + } + }, { "package": "XcodeProj", "repositoryURL": "https://github.com/tuist/XcodeProj.git", @@ -60,8 +114,8 @@ "repositoryURL": "https://github.com/jpsim/Yams.git", "state": { "branch": null, - "revision": "6652aa7b793d3c8a075db0614acb575fcaecf457", - "version": "0.7.0" + "revision": "9ff1cc9327586db4e0c8f46f064b6a82ec1566fa", + "version": "4.0.6" } }, { diff --git a/Package.swift b/Package.swift index f656344..ceb994f 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ import PackageDescription let package = Package( name: "General", - platforms: [.macOS(.v10_12)], + platforms: [.macOS(.v10_13)], products: [ .executable(name: "General", targets: ["General"]), .executable(name: "GeneralIOs", targets: ["GeneralIOs"]), @@ -16,11 +16,13 @@ let package = Package( ], dependencies: [ //with bumped PathKit version + .package(url: "https://github.com/kylef/PathKit", .branch("master")), .package(url: "https://github.com/rosberry/StencilSwiftKit.git", .branch("stable")), .package(url: "https://github.com/apple/swift-argument-parser.git", .upToNextMajor(from: "0.1.0")), - .package(url: "https://github.com/jpsim/Yams.git", .upToNextMajor(from: "0.0.0")), + .package(url: "https://github.com/jpsim/Yams.git", .upToNextMajor(from: "4.0.6")), .package(url: "https://github.com/weichsel/ZIPFoundation.git", .upToNextMajor(from: "0.9.0")), - .package(url: "https://github.com/tuist/XcodeProj.git", .upToNextMajor(from: "7.0.0")) + .package(url: "https://github.com/tuist/XcodeProj.git", .upToNextMajor(from: "7.0.0")), + .package(url: "https://github.com/rosberry/umaler.git", .branch("master")), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -42,6 +44,7 @@ let package = Package( name: "GeneralIOs", dependencies: [ .target(name: "GeneralKit"), + .product(name: "UmalerKit", package: "umaler"), "XcodeProj" ]) ] diff --git a/Sources/GeneralIOs/Commands/Bootstrap.swift b/Sources/GeneralIOs/Commands/Bootstrap.swift new file mode 100644 index 0000000..09f5b6b --- /dev/null +++ b/Sources/GeneralIOs/Commands/Bootstrap.swift @@ -0,0 +1,245 @@ +// +// Bootstrap.swift +// GeneralIOs +// +// Created by Nick Tyunin on 15.10.2021. +// + +import Foundation +import ArgumentParser +import Files +import UmalerKit +import GeneralKit +import Stencil + +final class Bootstrap: ParsableCommand { + static let configuration: CommandConfiguration = .init(abstract: "Allows to generate project from plant uml definition", + subcommands: [Make.self, Config.self], + defaultSubcommand: Make.self) + + enum Error: Swift.Error { + case template + case bundleId + case github + + var description: String { + switch self { + case .template: + return "No template path was specified. Please use `--template` option or specify it in reusable config " + + "with `bootsrap config update --template`" + case .bundleId: + return "No bundle id was specified. Please use --bundle_id option or provide company name using --company. " + + "You also can specify company in reusable config using `bootsrap config update --company`" + case .github: + return "Templates repo was not specified" + } + } + } + + final class Config: ParsableCommand { + + enum Constants { + static let template: String = "template" + static let company: String = "company" + static let firebase: String = "firebase" + static let swiftgen: String = "swiftgen" + static let licenseplist: String = "licenseplist" + } + + static let configuration: CommandConfiguration = .init(abstract: "Allows read or modify reusable bootstrap config", + subcommands: [UpdateConfig.self, PrintConfig.self], + defaultSubcommand: UpdateConfig.self) + + + } + + final class UpdateConfig: ParsableCommand { + + public typealias Dependencies = HasBootstrapService + static let configuration: CommandConfiguration = .init(commandName: "update", abstract: "Allows to modify reusable bootstrap config") + + @Option(name: .shortAndLong, help: "The path to the project template", completion: .directory) + var template: String? + + @Option(name: .shortAndLong, help: "The bundle identifier of new project") + var company: String? + + @Option(name: .shortAndLong, help: "Enable or disable firebase") + var firebase: Bool? + + @Option(name: .shortAndLong, help: "Enable or disable firebase") + var swiftgen: Bool? + + @Option(name: .shortAndLong, help: "Enable or disable licenseplist") + var licenseplist: Bool? + + @Option(name: .shortAndLong, help: "Set additional variable value. Format name=value ") + var variable: String? + + var dependencies: Dependencies { + Services + } + + func run() throws { + var config = dependencies.bootstrapService.config + if let template = self.template { + config[Config.Constants.template] = template + } + if let company = self.company { + config[Config.Constants.company] = company + } + if let firebase = self.firebase { + config[Config.Constants.firebase] = firebase + } + if let swiftgen = self.swiftgen { + config[Config.Constants.swiftgen] = swiftgen + } + if let licenseplist = self.licenseplist { + config[Config.Constants.licenseplist] = licenseplist + } + if let variable = self.variable { + let components = variable.split(separator: "=").map { component in + String(component.trimmingCharacters(in: .whitespaces)) + } + guard components.count == 2 else { + print(yellow("Invalid variable format provided: `\(variable)`. Missing `name=value`")) + return + } + let name = components[0] + let value = components[1] + config[name] = value + } + dependencies.bootstrapService.config = config + } + + } + + final class PrintConfig: ParsableCommand { + public typealias Dependencies = HasBootstrapService + static let configuration: CommandConfiguration = .init(commandName: "print", abstract: "Displays reusable bootstrap config") + + var dependencies: Dependencies { + Services + } + + func run() throws { + let config = dependencies.bootstrapService.config + guard let data = try? JSONSerialization.data(withJSONObject: config, options: .prettyPrinted), + let string = String(data: data, encoding: .utf8) else { + print(red("Could not parse stored config")) + return + } + print(green(string)) + } + } + + final class Make: ParsableCommand { + public typealias Dependencies = HasBootstrapService + + static let configuration: CommandConfiguration = .init(abstract: "Generate project from plant uml definition") + + @Option(name: .shortAndLong, help: "The name of new project") + var name: String + + @Option(name: .shortAndLong, help: "The path to uml diagrams file") + var uml: String? + + @Option(name: .shortAndLong, help: "The path to the project template", completion: .directory) + var template: String? + + @Option(name: .shortAndLong, help: "The bundle identifier of new project. If not specified will be composed using company and name values: `com.company.name`") + var bundleId: String? + + @Option(name: .shortAndLong, help: "The bundle identifier of new project") + var company: String? + + @Option(name: .shortAndLong, help: "Enable or disable firebase") + var firebase: Bool? + + @Option(name: .shortAndLong, help: "Enable or disable swiftgen") + var swiftgen: Bool? + + @Option(name: .shortAndLong, help: "Enable or disable licenseplist") + var licenseplist: Bool? + + // @Option(name: .shortAndLong, help: "Path to plant uml file", completion: .directory) + // var uml: String + + @Option(name: [.customLong("repo"), .customShort("r")], + help: .init(stringLiteral: + "Fetch templates from specified github repo." + + " Format: \"\\ [branch]\"."), + completion: .templatesRepos) + var githubPath: String? + + var dependencies: Dependencies { + Services + } + + func run() throws { + let config = uml != nil ? try composeUMLConfig() : try composeProjectConfig() + try dependencies.bootstrapService.bootstrap(with: config) + } + + private func composeUMLConfig() throws -> BootstrapConfig { + var projectConfig = dependencies.bootstrapService.config + guard let template = self.template ?? projectConfig[Config.Constants.template] as? String else { + throw Error.template + } + projectConfig.removeValue(forKey: Config.Constants.template) + if let company = self.company { + projectConfig[Config.Constants.company] = company + } + if let firebase = self.firebase { + projectConfig[Config.Constants.firebase] = firebase + } + if let swiftgen = self.swiftgen { + projectConfig[Config.Constants.swiftgen] = swiftgen + } + if let licenseplist = self.licenseplist { + projectConfig[Config.Constants.licenseplist] = licenseplist + } + projectConfig["name"] = name + projectConfig["year"] = "\(Calendar.current.component(.year, from: Date()))" + if let bundleId = self.bundleId { + projectConfig["bundle_identifier"] = bundleId + } else if let company = self.company ?? projectConfig[Config.Constants.company] as? String { + projectConfig["bundle_identifier"] = "com.\(company).\(name)".lowercased() + } else { + throw Error.bundleId + } + var context = [String: Any]() + context["project"] = projectConfig + return .init(name: name, context: context, template: template, diagrams: uml) + } + + private func composeProjectConfig() throws -> BootstrapConfig { + var projectConfig = dependencies.bootstrapService.config + guard let template = self.template ?? projectConfig[Config.Constants.template] as? String else { + throw Error.template + } + projectConfig.removeValue(forKey: Config.Constants.template) + if let company = self.company ?? projectConfig[Config.Constants.company] as? String { + projectConfig["organization_name"] = company + } + if let firebase = self.firebase ?? projectConfig[Config.Constants.firebase] as? Bool { + projectConfig[Config.Constants.firebase] = firebase ? "Yes" : "No" + } + if let swiftgen = self.swiftgen ?? projectConfig[Config.Constants.swiftgen] as? Bool { + projectConfig[Config.Constants.swiftgen] = swiftgen ? "Yes" : "No" + } + if let licenseplist = self.licenseplist ?? projectConfig[Config.Constants.licenseplist] as? Bool { + projectConfig[Config.Constants.licenseplist] = licenseplist ? "Yes" : "No" + } + projectConfig["name"] = name + if let bundleId = self.bundleId { + projectConfig["bundle_identifier"] = bundleId + } else if let company = self.company ?? projectConfig[Config.Constants.company] as? String { + projectConfig["bundle_identifier"] = "com.\(company).\(name)".lowercased() + } else { + throw Error.bundleId + } + return .init(name: name, context: projectConfig, template: template, diagrams: nil) + } + } +} diff --git a/Sources/GeneralIOs/Models/BootstrapConfig.swift b/Sources/GeneralIOs/Models/BootstrapConfig.swift new file mode 100644 index 0000000..4e9b090 --- /dev/null +++ b/Sources/GeneralIOs/Models/BootstrapConfig.swift @@ -0,0 +1,13 @@ +// +// BootstrapConfig.swift +// GeneralIOs +// +// Created by Nick Tyunin on 11.11.2021. +// + +public struct BootstrapConfig { + let name: String + let context: [String: Any] + let template: String + let diagrams: String? +} diff --git a/Sources/GeneralIOs/Services/BootstrapService/BootstrapService.swift b/Sources/GeneralIOs/Services/BootstrapService/BootstrapService.swift new file mode 100644 index 0000000..2d74f4c --- /dev/null +++ b/Sources/GeneralIOs/Services/BootstrapService/BootstrapService.swift @@ -0,0 +1,12 @@ +// +// Copyright © 2021 Rosberry. All rights reserved. +// + +public protocol HasBootstrapService: AnyObject { + var bootstrapService: BootstrapService { get } +} + +public protocol BootstrapService: AnyObject { + var config: [String: Any] { get set } + func bootstrap(with config: BootstrapConfig) throws +} diff --git a/Sources/GeneralIOs/Services/BootstrapService/BootstrapServiceImpl.swift b/Sources/GeneralIOs/Services/BootstrapService/BootstrapServiceImpl.swift new file mode 100644 index 0000000..1d1a8c8 --- /dev/null +++ b/Sources/GeneralIOs/Services/BootstrapService/BootstrapServiceImpl.swift @@ -0,0 +1,87 @@ +// +// Copyright © 2021 Rosberry. All rights reserved. +// +import UmalerKit +import GeneralKit +import Stencil +import Foundation + +public final class BootstrapServiceImpl: BootstrapService { + + private lazy var configPath: String = "\(Constants.generalHomePath)/.bootstrap" + + typealias Dependencies = HasFileHelper & HasProjectServiceFactory & HasShell + + private var dependencies: Dependencies + + public var config: [String : Any] { + get { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: configPath)), + let config = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else { + return [:] + } + return config + } + set { + guard let data = try? JSONSerialization.data(withJSONObject: newValue, options: .fragmentsAllowed) else { + return + } + try? data.write(to: URL(fileURLWithPath: configPath)) + } + } + + init(dependencies: Dependencies) { + self.dependencies = dependencies + } + + + public func bootstrap(with config: BootstrapConfig) throws { + if config.diagrams != nil { + let bootsraper = UMLBootstraper(dependencies: dependencies) + try bootsraper.bootstrap(with: config) + } + else { + try bootstrapProject(with: config) + } + + swiftgen(with: config) + //try depo(with: config) + } + + private func bootstrapProject(with config: BootstrapConfig) throws { + var arguments = ["--no-input", config.template] + arguments.append(contentsOf: config.context.map { key, value in + "\(key)=\(value)" + }) + try dependencies.shell(path: "/usr/local/bin/cookiecutter", arguments: arguments) + } + + private func depo(with config: BootstrapConfig) throws { + guard isInstalled(tool: "depo"), + isExists(path: "\(config.name)/Depofile") else { + return + } + let shell = dependencies.shell + try shell(loud: "(cd \(config.name) && depo install)") + } + + private func swiftgen(with config: BootstrapConfig) { + guard isInstalled(tool: "swiftgen") else { + return + } + let shell = dependencies.shell + _ = try? shell(loud: "(cd \(config.name) && swiftgen)") + } + + private func isExists(path: String) -> Bool{ + return dependencies.fileHelper.fileManager.fileExists(atPath: path) + } + + private func isInstalled(tool: String) -> Bool { + guard let path = try? dependencies.shell(silent: "command -v \(tool)"), + path.isEmpty == false else { + return false + } + return true + } +} diff --git a/Sources/GeneralIOs/Services/BootstrapService/UMLBootstraper.swift b/Sources/GeneralIOs/Services/BootstrapService/UMLBootstraper.swift new file mode 100644 index 0000000..dfccb93 --- /dev/null +++ b/Sources/GeneralIOs/Services/BootstrapService/UMLBootstraper.swift @@ -0,0 +1,590 @@ +// +// Bootstraper.swift +// GeneralIOs +// +// Created by Nick Tyunin on 28.10.2021. +// + +import UmalerKit +import GeneralKit +import Stencil +import StencilSwiftKit +import Yams +import PathKit +import Foundation + +final class UMLBootstraper { + + enum Error: LocalizedError { + case architecture(String) + case noDiagrams + + var errorDescription: String? { + switch self { + case let .architecture(path): + return "Could not parse architecture using specified uml path \(path)" + case .noDiagrams: + return "No diagrams were specified" + } + } + } + + enum StringResolve { + case resolved(String) + case unresolved([ArchitectureTemplateItem.MatchToken]) + case renderFailure + } + + enum Resolve { + case resolvedDirectory(String) + case unresolvedDirectoryName(StringResolve) + case resolvedFile(String, String) + case unresolvedFile + case unresolvedFileName(StringResolve) + case renderFailure + } + + var context: [String: Any] = [:] + private lazy var environment: Environment = { + let environment = Environment(loader: nil, extensions: [], templateClass: VariablesTemplate.self) + environment.extensions.forEach { ext in + ext.registerStencilSwiftExtensions() + } + return environment + }() + + private lazy var architectureParser = ArchitectureUMLParser() + private lazy var diagramsParser = PlantUMLParser() + private lazy var plantuml = Plantuml() + private lazy var projectService: ProjectService = { + let service: ProjectService + if let projectContext = context["project"] as? [String: Any], + let projectName = projectContext["name"] as? String, + let path = try? ProjectService.findProject(in: .current + projectName) { + service = dependencies.projectServiceFactory.makeProjectService(path: path.parent()) + try? service.createProject(projectName: path.lastComponent) + } else { + service = dependencies.projectServiceFactory.makeProjectService(path: .current) + } + return service + }() + + typealias Dependencies = HasFileHelper & HasProjectServiceFactory + + private var dependencies: Dependencies + + init(dependencies: Dependencies) { + self.dependencies = dependencies + } + + public func bootstrap(with config: BootstrapConfig) throws { + initContext(with: config) + guard let template = try createProjectFiles(template: config.template, destination: ".") else { + return + } + guard let diagramsPath = config.diagrams else { + throw Error.noDiagrams + } + let diagrams = try parseDiagrams(path: diagramsPath) + guard let architecture = try parseArchitecture( + diagrams: diagrams, + template: template, + methodsExcepts: [ + "success", + "failure", + "finish" + ]) else { + throw Error.architecture(diagramsPath) + } + // First try generate basic implementation and compose specific context + try bootstrap(item: architecture, destination: ".") + // Second try regenerate specific files implementations using modified context + try bootstrap(item: architecture, destination: ".") + try dependencies.fileHelper.removeFile(at: .init(fileURLWithPath: "./.boot")) + try projectService.write() + } + + private func initContext(with config: BootstrapConfig) { + context = config.context + } + + private func createProjectFiles(template: String, destination: String) throws -> (ArchitectureTemplateItem?) { + let fileInfo = try dependencies.fileHelper.fileInfo(with: URL(fileURLWithPath: expandingTildeInPath(template))) + let status = resolve(fileInfo) + + switch status { + case let .resolvedDirectory(name): + return try composeFolder(fileInfo: fileInfo, tokens: [.concrete(name)], name: name, destination: destination) + case let .resolvedFile(name, content): + try content.write(toFile: "\(destination)/\(name)", atomically: true, encoding: .utf8) + case let .unresolvedDirectoryName(resolve): + switch resolve { + case .resolved: + print("Invalid resolved status for template at \(fileInfo.url)") + case let .unresolved(tokens): + return try composeFolder(fileInfo: fileInfo, tokens: tokens, name: nil, destination: destination) + case .renderFailure: + print("Could not render file name of template \(fileInfo.url)") + } + case let .unresolvedFileName(resolve): + switch resolve { + case .resolved: + print("Invalid resolved status for template at \(fileInfo.url)") + case let .unresolved(tokens): + return try makeTemplateFile(fileInfo: fileInfo, tokens: tokens, destination: destination) + case .renderFailure: + return try makeTemplateFile(fileInfo: fileInfo, tokens: [], destination: destination) + } + case .unresolvedFile: + return try makeTemplateFile(fileInfo: fileInfo, tokens: [], destination: destination) + case .renderFailure: + return try makeTemplateFile(fileInfo: fileInfo, tokens: [], destination: destination) + } + return nil + } + + private func composeFolder(fileInfo: FileInfo, tokens: [ArchitectureTemplateItem.MatchToken], name: String?, destination: String) throws -> ArchitectureTemplateItem? { + if let name = name { + try dependencies.fileHelper.createDirectory(at: "\(destination)/\(name)") + } + let folder = bootPath(destination: destination, name: name ?? fileInfo.url.lastPathComponent) + try dependencies.fileHelper.createDirectory(at: folder) + let filesDestination = "\(destination)/\(name ?? fileInfo.url.lastPathComponent)" + let items = try dependencies.fileHelper.contentsOfDirectory(at: fileInfo.url).compactMap { fileInfo in + try createProjectFiles(template: fileInfo.url.path, destination: filesDestination) + } + guard items.isEmpty == false else { + let url = URL(fileURLWithPath: folder) + try dependencies.fileHelper.removeFile(at: url) + return nil + } + return .folder(tokens, items) + } + + private func makeTemplateFile(fileInfo: FileInfo, tokens: [ArchitectureTemplateItem.MatchToken], destination: String) throws -> ArchitectureTemplateItem? { + guard fileInfo.url.lastPathComponent != ".DS_Store" else { + return nil + } + let floder = ".boot/\(destination)" + try dependencies.fileHelper.createDirectory(at: floder) + let path = "\(floder)/\(fileInfo.url.lastPathComponent)" + guard let data = try? Data(contentsOf: fileInfo.url) else { + print("Could not read template file at \(fileInfo.url)") + return nil + } + try data.write(to: URL(fileURLWithPath: path), options: .atomic) + let name = fileInfo.url.deletingPathExtension().lastPathComponent + switch resolveVariables(string: name) { + case let .unresolved(tokens): + return .file(tokens) + default: + return .file([.concrete(name)]) + } + } + + private func resolve(_ fileInfo: FileInfo) -> Resolve { + let fileNameResolve = resolveFileName(fileInfo) + switch fileNameResolve { + case let .resolved(fileName): + guard fileInfo.isDirectory == false else { + return .resolvedDirectory(fileName) + } + return resolveFile(with: fileName, fileInfo: fileInfo) + case .unresolved: + return fileInfo.isDirectory ? .unresolvedDirectoryName(fileNameResolve) : .unresolvedFileName(fileNameResolve) + case .renderFailure: + return .renderFailure + } + } + + private func resolveFile(with fileName: String, fileInfo: FileInfo) -> Resolve { + guard let data = try? Data(contentsOf: fileInfo.url), + let string = String(data: data, encoding: .utf8) else { + return .renderFailure + } + let template = VariablesTemplate(templateString: string, environment: environment) + let unresolvedVariables = self.unresolvedVariables(in: template) + guard unresolvedVariables.isEmpty else { + return .unresolvedFile + } + guard let content = try? template.render(context) else { + return .renderFailure + } + return .resolvedFile(fileName, content) + } + + private func resolveFileName(_ fileInfo: FileInfo) -> StringResolve { + let fileName = fileInfo.url.lastPathComponent.trimmingCharacters(in: .whitespaces) + return resolveVariables(string: fileName) + } + + private func resolveVariables(string: String) -> StringResolve { + let template = VariablesTemplate(templateString: string, environment: environment) + let unresolvedVariables = self.unresolvedVariables(in: template) + guard unresolvedVariables.isEmpty else { + return .unresolved(tokenize(string: string, variables: unresolvedVariables, template: template)) + } + guard let value = try? template.render(context) else { + return .renderFailure + } + return .resolved(value) + } + + private func tokenize(string: String, variables: [String], template: VariablesTemplate) -> [ArchitectureTemplateItem.MatchToken] { + let pattern = "\\{\\{\\s*([a-zA-Z][a-zA-Z0-9.]*)\\s*\\}\\}" + let matches = parseAllRegexMatches(pattern: pattern, rangeIndex: 0, string: string) + var tokens = [ArchitectureTemplateItem.MatchToken]() + var startIndex = string.startIndex + + func unresolved(match: String) -> String? { + variables.first { string in + match.contains(string) + } + } + + matches.forEach { match in + guard let range = string.range(of: match) else { + return + } + let prefix = String(string[startIndex.. [String] { + template.variables.compactMap { variable -> String? in + hasValue(for: variable) ? nil : variable + } + } + + private func hasValue(for variable: String) -> Bool { + hasValue(for: variable.split(separator: ".").map({ String($0) }), in: context) + } + + private func hasValue(for components: [String], in dictionary: [String: Any]) -> Bool { + guard let key = components.first?.trimmingCharacters(in: .whitespacesAndNewlines), + let anyValue = dictionary[key] else { + return false + } + guard components.count > 1 else { + return true + } + guard let dictionary = anyValue as? [String: Any] else { + return false + } + return hasValue(for: Array(components.dropFirst()), in: dictionary) + } + + private func expandingTildeInPath(_ path: String) -> String { + return path.replacingOccurrences(of: "~", with: FileManager.default.homeDirectoryForCurrentUser.path) + } + + private func parseDiagrams(path: String) throws -> [Diagram] { + let preprocessedPath = try plantuml.preprocessed(path: path, mode: "ios") + let diagrams = try diagramsParser.parse(path: preprocessedPath) + return diagrams + } + + private func parseArchitecture(diagrams: [Diagram], + template: ArchitectureTemplateItem, + methodsExcepts: [String]) throws -> ArchitectureItem? { + try architectureParser.parse(diagrams: diagrams, using: [template] + methodsExcepts.map { name in + .except(.method([.concrete(name)])) + }).first + } + + @discardableResult + private func bootstrap(item: ArchitectureItem, destination: String) throws -> Resolve { + switch item { + case let .folder(folder): + return try bootstrap(folder: folder, destination: destination) + case let .object(object): + return try bootstrap(object: object, destination: destination) + } + } + + @discardableResult + private func bootstrap(folder: ArchitectureItem.Folder, destination: String) throws -> Resolve { + let path = "\(destination)/\(folder.name)" + try dependencies.fileHelper.createDirectory(at: path) + guard let template = self.template(for: destination, name: folder.name), + let files = try? dependencies.fileHelper.contentsOfDirectory(at: template.url) else { + try folder.items.forEach { item in + try bootstrap(item: item, destination: path) + } + return .resolvedDirectory(folder.name) + } + + func item(for file: FileInfo) -> ArchitectureItem? { + let fileName = fileNameBase(from: file) + return folder.items.first { item in + return isMatch(pattern: fileName, using: name(of: item)) + } + } + + func bootstrapMissingFile(_ file: FileInfo, destination: String) throws { + guard file.url.lastPathComponent.lowercased() != ".ds_store" else { + return + } + switch resolve(file) { + case let .resolvedFile(name, content): + try addFile(name: name, destination: destination, content: content) + case let .resolvedDirectory(name): + let path = "\(destination)/\(name)" + try dependencies.fileHelper.createDirectory(at: path) + try dependencies.fileHelper.contentsOfDirectory(at: file.url).forEach { file in + try bootstrapMissingFile(file, destination: path) + } + default: + break + } + } + + let fileName = fileNameBase(from: template) + let variables = resolveContext(pattern: fileName, using: folder.name) + + func renderFiles() throws { + try folder.items.forEach { item in + try bootstrap(item: item, destination: path) + } + + try files.forEach { file in + try bootstrapMissingFile(file, destination: path) + } + } + + try renderFiles() + + variables.forEach { name in + context = clean(context: context, name: name) ?? context + } + return .resolvedDirectory(folder.name) + } + + @discardableResult + private func bootstrap(object: ArchitectureItem.Object, destination: String) throws -> Resolve { + guard let template = self.template(for: destination, name: object.name) else { + return .renderFailure + } + var methods = [[String: Any]]() + object.methods.forEach { method in + var methodContext = [String:Any]() + methodContext["name"] = method.name + methodContext["calls"] = method.calls.map{ call -> [String: Any] in + var callContext = [String: Any]() + callContext["name"] = call.name + callContext["called"] = call.called + return callContext + } + methods.append(methodContext) + } + context[object.name] = ["methods": methods] + let fileName = fileNameBase(from: template) + resolveContext(pattern: fileName, using: object.name) + let resolve = self.resolve(template) + switch resolve { + case let .resolvedFile(name, content): + try addFile(name: name, destination: destination, content: content) + default: + break + } + return resolve + } + + private func template(for destination: String, name: String?) -> FileInfo? { + let bootUrl = URL(fileURLWithPath: bootPath(destination: destination)) + + guard let fileInfo = try? dependencies.fileHelper.fileInfo(with: bootUrl) else { + return nil + } + + func findMatchFile(folder: FileInfo, name: String?) -> FileInfo? { + guard let name = name else { + return folder + } + guard let files = try? dependencies.fileHelper.contentsOfDirectory(at: folder.url) else { + return nil + } + return files.first { file in + let fileName = fileNameBase(from: file) + guard fileName != name else { + return true + } + return isMatch(pattern: fileName, using: name) + } + } + + guard fileInfo.isExists == false else { + return findMatchFile(folder: fileInfo, name: name) + } + var parentFolderPath = URL(fileURLWithPath: destination).deletingLastPathComponent().path + let rootPath = URL(fileURLWithPath: "./").path + parentFolderPath.removeFirst(rootPath.count) + parentFolderPath = "." + parentFolderPath + guard let parentFolderTemplate = template(for: parentFolderPath, name: nil), + let thisFolderTemplate = findMatchFile(folder: parentFolderTemplate, name: fileInfo.url.lastPathComponent) else { + return nil + } + return findMatchFile(folder: thisFolderTemplate, name: name) + } + + private func isMatch(pattern: String, using resolved: String) -> Bool { + let (isSuccess, _) = parseMatch(pattern: pattern, using: resolved, parseHandler: nil) + return isSuccess + } + + @discardableResult + private func resolveContext(pattern: String, using resolved: String) -> [String] { + let (isSuccess, values) = parseMatch(pattern: pattern, using: resolved) { name, value in + self.context = self.update(context: self.context, name: name, value: value) ?? self.context + } + guard isSuccess else { + return [] + } + return values + } + + private func parseMatch(pattern: String, using resolved: String, parseHandler: ((String, String) -> Void)?) -> (Bool, [String]) { + guard pattern != resolved else { + return (true, []) + } + var values = [String]() + let variablesResolve = resolveVariables(string: pattern) + let tokens: [ArchitectureTemplateItem.MatchToken] + switch variablesResolve { + case let .resolved(resolvedPattern): + return (resolvedPattern == resolved, []) + case let .unresolved(missingTokens): + tokens = missingTokens + case .renderFailure: + return (false, []) + } + let regex = tokens.map { token -> String in + switch token { + case let .concrete(name): + return name + default: + return "([a-zA-Z][a-zA-Z0-9]*)" + } + }.joined() + var matches = parseAllRegexMatches(pattern: regex, rangeIndex: 1, string: resolved) + guard matches.isEmpty == false else { + return (false, []) + } + var string = resolved + for token in tokens { + switch token { + case let .concrete(substing): + guard string.starts(with: substing) else { + return (false, []) + } + string.removeFirst(substing.count) + case let .variable(name): + guard matches.isEmpty == false else { + return (false, []) + } + let value = matches.removeFirst() + string.removeFirst(value.count) + parseHandler?(name, value) + values.append(name) + case .any: + guard matches.isEmpty == false else { + continue + } + let value = matches.removeFirst() + string.removeFirst(value.count) + } + } + return (true, values) + } + + private func update(context: [String: Any]?, name: String, value: String) -> [String: Any]? { + update(context: context, path: name.split(separator: ".").map{ String($0)}, value: value) + } + + private func update(context: [String: Any]?, path: [String], value: String) -> [String: Any]? { + guard let key = path.first else { + return nil + } + var path = path + path.removeFirst() + + var context = context ?? [:] + if path.isEmpty { + context[key] = value + } else { + context[key] = update(context: context[key] as? [String:Any], path: path, value: value) + } + return context + } + + private func clean(context: [String: Any]?, name: String) -> [String: Any]? { + guard let rootKey = name.split(separator: ".").first else { + return context + } + var context = context + context?.removeValue(forKey: String(rootKey)) + return context + } + + private func fileNameBase(from fileInfo: FileInfo) -> String { + if fileInfo.isDirectory { + return fileInfo.url.lastPathComponent + } + else { + return fileInfo.url.deletingPathExtension().lastPathComponent + } + } + + private func bootPath(destination: String) -> String { + let path: String + if let prefix = [".//", "./", "."].first { destination.contains($0) } { + var destination = destination + destination.removeFirst(prefix.count) + if destination.isEmpty { + path = ".boot" + } else { + path = ".boot/\(destination)" + } + } else { + path = ".boot/\(destination)" + } + return path + } + + private func bootPath(destination: String, name: String) -> String { + "\(bootPath(destination: destination))/\(name)" + } + + private func name(of item: ArchitectureItem) -> String { + switch item { + case let .folder(folder): + return folder.name + case let .object(object): + return object.name + } + } + + private func addFile(name: String, destination: String, content: String) throws { + let path = "\(destination)/\(name)" + try content.write(toFile: path, atomically: true, encoding: .utf8) + try projectService.addFile(targetName: nil, filePath: .current + .init(path)) + } +} diff --git a/Sources/GeneralIOs/Services/FontService/FontService.swift b/Sources/GeneralIOs/Services/FontService/FontService.swift deleted file mode 100644 index 61533f6..0000000 --- a/Sources/GeneralIOs/Services/FontService/FontService.swift +++ /dev/null @@ -1,295 +0,0 @@ -// -// Created by Evgeny Schwarzkopf on 08.04.2022. -// - -import Foundation -import ArgumentParser -import GeneralKit -import AEXML -import Stencil -import PathKit - -public final class FontService { - - private enum Error: Swift.Error, LocalizedError { - case notFoundFonts(String) - case notFoundTarget(String) - case notFoundInfoPlist(String) - case invalidData - case notFoundTemplate - case somethingGoingWrong(String, String) - - public var errorDescription: String? { - switch self { - case let .notFoundFonts(path): - return red("Is not found fonts in path \(path). Please check correctly path.") - case let .notFoundTarget(targetName): - return red("Is not found target - \(targetName)") - case let .notFoundInfoPlist(path): - return red("Is not found info plist by path \(path)") - case .invalidData: - return red("Data is not valid. Please check correctly Info.plist") - case .notFoundTemplate: - return red(#""" - Is not found folder templates in directory. - Please, call command `general setup -r rosberry/general-templates\ ios` - """#) - case .somethingGoingWrong(let title, let subtitle): - return red(""" - Something going wrong... - I try it \(title) ... - But I can't perform because \(subtitle). - Please check and try again. - """) - } - } - } - - typealias Dependencies = HasFileHelper & - HasProjectServiceFactory & - HasSpecFactory - - private lazy var fileHelper = dependencies.fileHelper - - private lazy var projectService: ProjectService = { - let service = dependencies.projectServiceFactory.makeProjectService(path: "./") - try? service.createProject(projectName: "\(unwrappedProjectName).xcodeproj") - return service - }() - - private lazy var specFactory: SpecFactory = dependencies.specFactory - - private lazy var generalSpec: GeneralSpec? = { - let pathURL = URL(fileURLWithPath: directoryPath, isDirectory: true) - let specURL = URL(fileURLWithPath: Constants.generalSpecName, relativeTo: pathURL) - return try? specFactory.makeSpec(url: specURL) - }() - - private lazy var projectName: String? = { - return (try? fileHelper.contentsOfDirectory(at: "./").first { file in - file.url.pathExtension == "xcodeproj" - })?.url.deletingPathExtension().lastPathComponent - }() - - private lazy var unwrappedProjectName: String = { - return projectName ?? ask("Please enter project name") ?? "" - }() - - private var dependencies: Dependencies { - Services - } - - private let directoryPath: String - - public init(directoryPath: String) { - self.directoryPath = directoryPath - } - - public func addFontsInProject(_ fontsPath: String, target: String?) throws { - let fonts = fonts(in: URL(fileURLWithPath: fontsPath)) - let infoPlistURL = URL(fileURLWithPath: "./\(unwrappedProjectName)/Info.plist") - - guard fonts.isEmpty == false else { - throw Error.notFoundFonts(fontsPath) - } - - let targetName = target ?? unwrappedProjectName - - guard targetName != "" else { - throw Error.notFoundTarget(targetName) - } - - guard let fontSpec = generalSpec?.font else { - throw Error.notFoundTemplate - } - - try createGroupIfNeeded(fontSpec) - - guard let infoPlistData = try? Data(contentsOf: infoPlistURL) else { - throw Error.notFoundInfoPlist(infoPlistURL.path) - } - - let appFonts = try writeFontsInInfoPlistAndFolderFonts(fonts, - infoPlistData: infoPlistData, - infoPlistPath: infoPlistURL.path, - fontSpec: fontSpec, - target: targetName) - - try renderTemplateAndWrite(appFonts: appFonts, fontSpec: fontSpec) - - // Same thing - sleep(1) - - try addFileInProject(targetName, filePath: Path(fontSpec.extensionFontPath)) - try write() - } - - private func write() throws { - do { - try projectService.write() - print("✨ \(green("Successfully")) completed added fonts... ✨") - } - catch { - throw Error.somethingGoingWrong("save changes", error.localizedDescription) - } - } - - private func addFileInProject(_ target: String, filePath: Path) throws { - do { - try projectService.addFile(targetName: target, filePath: filePath, sourceTree: .group) - } - catch { - throw Error.somethingGoingWrong("add file in target", error.localizedDescription) - } - } - - private func renderTemplateAndWrite(appFonts: AEXMLElement, fontSpec: FontSpec) throws { - guard fileHelper.fileManager.fileExists(atPath: fontSpec.commandTemplatePath) else { - throw Error.notFoundTemplate - } - - let env = Environment(loader: FileSystemLoader(paths: [ - Path(fontSpec.fontsTemplatePath), - Path(fontSpec.commonTemplatePath) - ])) - - env.extensions.forEach { ext in - ext.registerStencilSwiftExtensions() - ext.registerFilter("fontFunction") { arg in - guard let name = arg as? String else { - return arg - } - return name.filter { !"-".contains($0) }.capitalizingFirstLetter() - } - } - - let fontName = appFonts.children.compactMap { font in - URL(fileURLWithPath: font.value ?? "").deletingPathExtension().lastPathComponent - } - - let company = generalSpec?.xcode.company ?? ask("Please enter license company") ?? "" - let year = Calendar.current.component(.year, from: .init()) - - guard let result = try? env.renderTemplate(name: fontSpec.fontTemplateName, - context: ["fonts": fontName, - "year": year, - "company": company]) else { - throw Error.somethingGoingWrong("generate ", "check font template in ./templates") - } - - do { - try result.write(toFile: fontSpec.extensionFontPath, atomically: true, encoding: .utf8) - } - catch { - throw Error.somethingGoingWrong("write extension font", error.localizedDescription) - } - } - - private func writeFontsInInfoPlistAndFolderFonts(_ fonts: [FileInfo], - infoPlistData: Data, - infoPlistPath: String, - fontSpec: FontSpec, target: String) throws -> AEXMLElement { - guard let xmlDoc = try? AEXMLDocument(xml: infoPlistData) else { - throw Error.invalidData - } - - let plsit = xmlDoc.root["dict"] - - let index = plsit.children.firstIndex { e in - e.value == fontSpec.infoFontName - } - - let appFonts: AEXMLElement - if let i = index { - appFonts = plsit.children[i + 1]; - } - else { - plsit.addChild(.init(name: "key", value: fontSpec.infoFontName)) - appFonts = .init(name: "array") - plsit.addChild(appFonts) - } - - try addFontsInInfoPlistAndFolderFonts(with: fonts, - fontsFolderPath: fontSpec.fontsFolderPath, - appFonts: appFonts, - target: target) - - try xmlDoc.xml.write(toFile: infoPlistPath, atomically: true, encoding: .utf8) - return appFonts - } - - private func addFontsInInfoPlistAndFolderFonts(with fonts: [FileInfo], - fontsFolderPath: String, - appFonts: AEXMLElement, - target: String) throws { - for newFont in fonts { - if appFonts.children.contains(where: { $0.value == newFont.url.lastPathComponent }) == false { - appFonts.addChild(.init(name: "string", value: newFont.url.lastPathComponent)) - let destination = URL(fileURLWithPath: fontsFolderPath + "/" + newFont.url.lastPathComponent) - if fileHelper.fileManager.fileExists(atPath: destination.path) == false { - do { - try fileHelper.fileManager.copyItem(at: newFont.url, to: destination) - } - catch { - throw Error.somethingGoingWrong("copy item", error.localizedDescription) - } - - // Here setup sleep 1 second when copy item in directory after install target for file. - // if sleep delete when target is not install for file because cannot execute asynchronously. - sleep(1) - - do { - try projectService.addFile(targetName: target, - filePath: Path(destination.relativePath), - sourceTree: .group, - isResource: true) - print("🎉 \(green("Added font:")) \(newFont.url.lastPathComponent) ... 🎉") - } - catch { - throw Error.somethingGoingWrong("add target", error.localizedDescription) - } - } - } - } - } - - private func createGroupIfNeeded(_ fontSpec: FontSpec) throws { - if fileHelper.fileManager.fileExists(atPath: fontSpec.fontsFolderPath) == false { - try fileHelper.createDirectory(at: fontSpec.fontsFolderPath) - } - - if fileHelper.fileManager.fileExists(atPath: fontSpec.extensionFolderPath) == false { - try fileHelper.createDirectory(at: fontSpec.extensionFolderPath) - } - } - - private func fonts(in folder: URL) -> [FileInfo] { - guard let filesInfo = try? fileHelper.contentsOfDirectory(at: folder) else { - return [] - } - return filesInfo.flatMap { file -> [FileInfo] in - if file.isDirectory { - return fonts(in: file.url) - } - - let pathExtenstion = file.url.pathExtension - switch pathExtenstion { - case "otf", "ttf": - return [file] - default: - break - } - return [] - } - } -} - -private extension String { - func capitalizingFirstLetter() -> String { - return prefix(1).lowercased() + self.dropFirst() - } - - mutating func capitalizeFirstLetter() { - self = self.capitalizingFirstLetter() - } -} diff --git a/Sources/GeneralIOs/Services/FontService/FontServiceFactory.swift b/Sources/GeneralIOs/Services/FontService/FontServiceFactory.swift deleted file mode 100644 index af7c4d0..0000000 --- a/Sources/GeneralIOs/Services/FontService/FontServiceFactory.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// Created by Evgeny Schwarzkopf on 08.04.2022. -// - -import Foundation -import PathKit - -public protocol HasFontServiceFactory { - var fontServiceFactory: FontServiceFactory { get } -} - -public class FontServiceFactory { - func makeFontService( directoryPath: String) -> FontService { - return .init(directoryPath: directoryPath) - } -} - diff --git a/Sources/GeneralIOs/Services/ProjectService/ProjectService.swift b/Sources/GeneralIOs/Services/ProjectService/ProjectService.swift index b45c89c..faf97d3 100644 --- a/Sources/GeneralIOs/Services/ProjectService/ProjectService.swift +++ b/Sources/GeneralIOs/Services/ProjectService/ProjectService.swift @@ -27,16 +27,17 @@ public final class ProjectService { try Path.current.children().first { $0.extension == Constants.xcodeProjectPathExtension } } + static func findProject(in path: Path) throws -> Path? { + try path.children().first { $0.extension == Constants.xcodeProjectPathExtension } + } + func createProject(projectName: String) throws { let xcodeprojPath = path + Path(projectName) xcodeproj = try XcodeProj(path: xcodeprojPath) self.xcodeprojPath = xcodeprojPath } - func addFile(targetName: String?, - filePath: Path, - sourceTree: PBXSourceTree = .sourceRoot, - isResource: Bool = false) throws { + func addFile(targetName: String?, filePath: Path) throws { guard let project = xcodeproj?.pbxproj.projects.first else { throw Error.noProject(path: path.string) } @@ -44,6 +45,7 @@ public final class ProjectService { // Folowing code fixes issue with absolute path linkinking. // Creating of relative urls based on project url becomes useless // due to XcodeProj internal issues. + let fullPath = filePath var filePath = filePath if filePath.string.contains(path.string) { var string = filePath.string.replacingOccurrences(of: path.string, with: "") @@ -60,12 +62,7 @@ public final class ProjectService { throw Error.noGroup } - let file = try group.addFile(at: filePath, sourceTree: sourceTree, sourceRoot: path) - - if isResource { - let _ = try xcodeproj?.pbxproj.resourcesBuildPhases.first?.add(file: file) - } - + let file = try group.addFile(at: fullPath, sourceTree: .sourceRoot, sourceRoot: path) xcodeproj?.pbxproj.add(object: file) let targets = xcodeproj?.pbxproj.nativeTargets let target: PBXNativeTarget? diff --git a/Sources/GeneralIOs/Services/ServiceFactory+GeneralIOs.swift b/Sources/GeneralIOs/Services/ServiceFactory+GeneralIOs.swift index ed35669..7976479 100644 --- a/Sources/GeneralIOs/Services/ServiceFactory+GeneralIOs.swift +++ b/Sources/GeneralIOs/Services/ServiceFactory+GeneralIOs.swift @@ -4,12 +4,12 @@ import GeneralKit -extension ServiceFactory: HasProjectServiceFactory, HasFontServiceFactory { +extension ServiceFactory: HasProjectServiceFactory, HasBootstrapService { public var projectServiceFactory: ProjectServiceFactory { .init() } - public var fontServiceFactory: FontServiceFactory { - .init() + public var bootstrapService: BootstrapService { + BootstrapServiceImpl(dependencies: self) } } diff --git a/Sources/GeneralKit/Models/CustomLexer.swift b/Sources/GeneralKit/Models/CustomLexer.swift new file mode 100644 index 0000000..6aa1444 --- /dev/null +++ b/Sources/GeneralKit/Models/CustomLexer.swift @@ -0,0 +1,366 @@ +// +// CustomLexer.swift +// GeneralKit +// +// Created by Nick Tyunin on 27.12.2021. +// + +import Foundation +import Stencil + +typealias Line = (content: String, number: UInt, range: Range) + +public struct CustomSourceMap: Equatable { + public let filename: String? + public let location: ContentLocation + + init(filename: String? = nil, location: ContentLocation = ("", 0, 0)) { + self.filename = filename + self.location = location + } + + static let unknown = CustomSourceMap() + + public static func == (lhs: CustomSourceMap, rhs: CustomSourceMap) -> Bool { + return lhs.filename == rhs.filename && lhs.location == rhs.location + } +} + +public class CustomToken: Equatable { + public enum Kind: Equatable { + /// A token representing a piece of text. + case text + /// A token representing a variable. + case variable + /// A token representing a comment. + case comment + /// A token representing a template block. + case block + } + + public let contents: String + public let kind: Kind + public let sourceMap: CustomSourceMap + + /// Returns the underlying value as an array seperated by spaces + public private(set) lazy var components: [String] = self.contents.smartSplit() + + init(contents: String, kind: Kind, sourceMap: CustomSourceMap) { + self.contents = contents + self.kind = kind + self.sourceMap = sourceMap + } + + /// A token representing a piece of text. + public static func text(value: String, at sourceMap: CustomSourceMap) -> CustomToken { + return CustomToken(contents: value, kind: .text, sourceMap: sourceMap) + } + + /// A token representing a variable. + public static func variable(value: String, at sourceMap: CustomSourceMap) -> CustomToken { + return CustomToken(contents: value, kind: .variable, sourceMap: sourceMap) + } + + /// A token representing a comment. + public static func comment(value: String, at sourceMap: CustomSourceMap) -> CustomToken { + return CustomToken(contents: value, kind: .comment, sourceMap: sourceMap) + } + + /// A token representing a template block. + public static func block(value: String, at sourceMap: CustomSourceMap) -> CustomToken { + return CustomToken(contents: value, kind: .block, sourceMap: sourceMap) + } + + public static func == (lhs: CustomToken, rhs: CustomToken) -> Bool { + return lhs.contents == rhs.contents && lhs.kind == rhs.kind && lhs.sourceMap == rhs.sourceMap + } +} + +struct CustomLexer { + let templateName: String? + let templateString: String + let lines: [Line] + + /// The potential token start characters. In a template these appear after a + /// `{` character, for example `{{`, `{%`, `{#`, ... + private static let tokenChars: [Unicode.Scalar] = ["{", "%", "#"] + + /// The token end characters, corresponding to their token start characters. + /// For example, a variable token starts with `{{` and ends with `}}` + private static let tokenCharMap: [Unicode.Scalar: Unicode.Scalar] = [ + "{": "}", + "%": "%", + "#": "#" + ] + + init(templateName: String? = nil, templateString: String) { + self.templateName = templateName + self.templateString = templateString + + self.lines = templateString.components(separatedBy: .newlines).enumerated().compactMap { + guard !$0.element.isEmpty, + let range = templateString.range(of: $0.element) else { return nil } + return (content: $0.element, number: UInt($0.offset + 1), range) + } + } + + /// Create a token that will be passed on to the parser, with the given + /// content and a range. The content will be tested to see if it's a + /// `variable`, a `block` or a `comment`, otherwise it'll default to a simple + /// `text` token. + /// + /// - Parameters: + /// - string: The content string of the token + /// - range: The range within the template content, used for smart + /// error reporting + func createToken(string: String, at range: Range) -> CustomToken { + func strip() -> String { + guard string.count > 4 else { return "" } + let trimmed = String(string.dropFirst(2).dropLast(2)) + .components(separatedBy: "\n") + .filter { !$0.isEmpty } + .map { $0.trim(character: " ") } + .joined(separator: " ") + return trimmed + } + + if string.hasPrefix("{{") || string.hasPrefix("{%") || string.hasPrefix("{#") { + let value = strip() + let range = templateString.range(of: value, range: range) ?? range + let location = rangeLocation(range) + let sourceMap = CustomSourceMap(filename: templateName, location: location) + if string.hasPrefix("{{") { + return .variable(value: value, at: sourceMap) + } else if string.hasPrefix("{%") { + return .block(value: value, at: sourceMap) + } else if string.hasPrefix("{#") { + return .comment(value: value, at: sourceMap) + } + } + + let location = rangeLocation(range) + let sourceMap = CustomSourceMap(filename: templateName, location: location) + return .text(value: string, at: sourceMap) + } + + /// Transforms the template into a list of tokens, that will eventually be + /// passed on to the parser. + /// + /// - Returns: The list of tokens (see `createToken(string: at:)`). + func tokenize() -> [CustomToken] { + var tokens: [CustomToken] = [] + + let scanner = Scanner(templateString) + while !scanner.isEmpty { + if let (char, text) = scanner.scanForTokenStart(CustomLexer.tokenChars) { + if !text.isEmpty { + tokens.append(createToken(string: text, at: scanner.range)) + } + + guard let end = CustomLexer.tokenCharMap[char] else { continue } + let result = scanner.scanForTokenEnd(end) + tokens.append(createToken(string: result, at: scanner.range)) + } else { + tokens.append(createToken(string: scanner.content, at: scanner.range)) + scanner.content = "" + } + } + + return tokens + } + + /// Finds the line matching the given range (for a token) + /// + /// - Parameter range: The range to search for. + /// - Returns: The content for that line, the line number and offset within + /// the line. + func rangeLocation(_ range: Range) -> ContentLocation { + guard let line = self.lines.first(where: { $0.range.contains(range.lowerBound) }) else { + return ("", 0, 0) + } + let offset = templateString.distance(from: line.range.lowerBound, to: range.lowerBound) + return (line.content, line.number, offset) + } +} + +class Scanner { + let originalContent: String + var content: String + var range: Range + + /// The start delimiter for a token. + private static let tokenStartDelimiter: Unicode.Scalar = "{" + /// And the corresponding end delimiter for a token. + private static let tokenEndDelimiter: Unicode.Scalar = "}" + + init(_ content: String) { + self.originalContent = content + self.content = content + range = content.unicodeScalars.startIndex.. String { + var foundChar = false + + for (index, char) in content.unicodeScalars.enumerated() { + if foundChar && char == Scanner.tokenEndDelimiter { + let result = String(content.unicodeScalars.prefix(index + 1)) + content = String(content.unicodeScalars.dropFirst(index + 1)) + range = range.upperBound.. (Unicode.Scalar, String)? { + var foundBrace = false + + range = range.upperBound.. String.Index? { + var index = startIndex + + while index != endIndex { + if character != self[index] { + return index + } + index = self.index(after: index) + } + + return nil + } + + func findLastNot(character: Character) -> String.Index? { + var index = self.index(before: endIndex) + + while index != startIndex { + if character != self[index] { + return self.index(after: index) + } + index = self.index(before: index) + } + + return nil + } + + func trim(character: Character) -> String { + let first = findFirstNot(character: character) ?? startIndex + let last = findLastNot(character: character) ?? endIndex + return String(self[first.. [String] { + var word = "" + var components: [String] = [] + var separate: Character = separator + var singleQuoteCount = 0 + var doubleQuoteCount = 0 + + for character in self { + if character == "'" { + singleQuoteCount += 1 + } else if character == "\"" { + doubleQuoteCount += 1 + } + + if character == separate { + if separate != separator { + word.append(separate) + } else if (singleQuoteCount % 2 == 0 || doubleQuoteCount % 2 == 0) && !word.isEmpty { + appendWord(word, to: &components) + word = "" + } + + separate = separator + } else { + if separate == separator && (character == "'" || character == "\"") { + separate = character + } + word.append(character) + } + } + + if !word.isEmpty { + appendWord(word, to: &components) + } + + return components + } + + private func appendWord(_ word: String, to components: inout [String]) { + let specialCharacters = ",|:" + + if !components.isEmpty { + if let precedingChar = components.last?.last, specialCharacters.contains(precedingChar) { + components[components.count - 1] += word + } else if specialCharacters.contains(word) { + components[components.count - 1] += word + } else if word != "(" && word.first == "(" || word != ")" && word.first == ")" { + components.append(String(word.prefix(1))) + appendWord(String(word.dropFirst()), to: &components) + } else if word != "(" && word.last == "(" || word != ")" && word.last == ")" { + appendWord(String(word.dropLast()), to: &components) + components.append(String(word.suffix(1))) + } else { + components.append(word) + } + } else { + components.append(word) + } + } +} diff --git a/Sources/GeneralKit/Models/VariablesTemplate.swift b/Sources/GeneralKit/Models/VariablesTemplate.swift new file mode 100644 index 0000000..096bbb7 --- /dev/null +++ b/Sources/GeneralKit/Models/VariablesTemplate.swift @@ -0,0 +1,114 @@ +// +// VariablesTemplate.swift +// GeneralIOs +// +// Created by Nick Tyunin on 11.11.2021. +// + +import Stencil +import StencilSwiftKit + +public class VariablesTemplate: Template { + + public var variables: [String] + public required init(templateString: String, environment: Environment? = nil, name: String? = nil) { + var variables: [String] = [] + var dynamicVariables: [String] = [] + let lexer = CustomLexer(templateString: templateString) + let tokens = lexer.tokenize() + var ifDepth = 0 + + func add(dynamic: String) { + guard dynamicVariables.contains(dynamic) == false else { + return + } + dynamicVariables.append(dynamic) + } + + func add(variable: String) { + guard ifDepth == 0 else { + return + } + var variable = variable + if let unfiltered = variable.split(separator: "|").first { + variable = String(unfiltered).trimmingCharacters(in: .whitespaces) + } + guard variables.contains(variable) == false else { + return + } + let components = variable.split(separator: ".") + for i in 0..= 4 else { + return + } + add(dynamic: "forloop") + add(dynamic: components[1]) + add(variable: components[3]) + case "only": + guard components.count >= 5 else { + return + } + add(dynamic: components[2]) + add(variable: components[4]) + case "using": + guard components.count >= 4 else { + return + } + add(dynamic: components[3]) + case "if": + ifDepth += 1 + case "endif": + ifDepth -= 1 + case "first": + add(dynamic: components[1]) + add(variable: components[3]) + default: + return + } + default: + return + } + } + self.variables = Array(Set(variables)) + super.init(templateString: templateString, environment: environment, name: name) + } + + public override func render(_ dictionary: [String : Any]? = nil) throws -> String { + var context = [String : Any]() + Export.global.forEach { key, value in + context[key] = value + } + dictionary?.forEach{ key, value in + context[key] = value + } + return try super.render(context) + } +} diff --git a/Sources/GeneralKit/Services/Shell/ShellImpl.swift b/Sources/GeneralKit/Services/Shell/ShellImpl.swift index fc4f4de..c0ef22c 100644 --- a/Sources/GeneralKit/Services/Shell/ShellImpl.swift +++ b/Sources/GeneralKit/Services/Shell/ShellImpl.swift @@ -43,6 +43,7 @@ public final class ShellImpl: Shell { } else { process.launch() } + process.waitUntilExit() let statusCode = process.terminationStatus if statusCode == 0 { return statusCode