From 5cd7f2029c9041d0482c51af01b160611baced64 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 18 Apr 2024 13:17:16 +0600 Subject: [PATCH] Implement SwiftLintPlugin as a standalone cli tool (#3) Task URL: https://app.asana.com/0/1201037661562251/1206803066031565/f BSK PR: duckduckgo/BrowserServicesKit#774 iOS PR: duckduckgo/iOS#2710 macOS PR: duckduckgo/macos-browser#2601 --- Package.resolved | 18 ++ Package.swift | 29 +- Plugins/SwiftLintPlugin/PathExtension.swift | 114 ++++++++ .../SwiftLintPlugin/ProcessExtension.swift | 54 ++++ Plugins/SwiftLintPlugin/SwiftLintPlugin.swift | 178 +++++++++--- .../SwiftLintToolExtensions.swift | 256 ++++++++++++++++++ 6 files changed, 602 insertions(+), 47 deletions(-) create mode 100644 Plugins/SwiftLintPlugin/ProcessExtension.swift create mode 100644 Plugins/SwiftLintPlugin/SwiftLintToolExtensions.swift diff --git a/Package.resolved b/Package.resolved index 53c4612..55c3761 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "46989693916f56d1186bd59ac15124caef896560", + "version" : "1.3.1" + } + }, { "identity" : "swift-macro-testing", "kind" : "remoteSourceControl", @@ -26,6 +35,15 @@ "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", "version" : "509.1.1" } + }, + { + "identity" : "xcodeeditor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/appsquickly/XcodeEditor.git", + "state" : { + "branch" : "master", + "revision" : "f3234db7fc40d8e917e169bc937ed03ca76ba885" + } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index 20f19c6..c42f563 100644 --- a/Package.swift +++ b/Package.swift @@ -11,41 +11,43 @@ let package = Package( .macOS("11.4") ], products: [ - .plugin(name: "SwiftLintPlugin", targets: ["SwiftLintPlugin"]), .library(name: "Macros", targets: ["Macros"]), + .executable(name: "SwiftLintTool", targets: ["SwiftLintTool"]), ], dependencies: [ // Depend on the Swift 5.9 release of SwiftSyntax .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0"), .package(url: "https://github.com/pointfreeco/swift-macro-testing.git", from: "0.2.2"), + .package(url: "https://github.com/appsquickly/XcodeEditor.git", branch: "master"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), ], targets: [ - .plugin( - name: "SwiftLintPlugin", - capability: .buildTool(), - dependencies: [ - .target(name: "SwiftLintBinary", condition: .when(platforms: [.macOS])) - ] - ), .binaryTarget( name: "SwiftLintBinary", url: "https://github.com/realm/SwiftLint/releases/download/0.54.0/SwiftLintBinary-macos.artifactbundle.zip", checksum: "963121d6babf2bf5fd66a21ac9297e86d855cbc9d28322790646b88dceca00f1" ), + .executableTarget( + name: "SwiftLintTool", + dependencies: [ + "SwiftLintBinary", + .product(name: "XcodeEditor", package: "XcodeEditor"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + path: "Plugins/SwiftLintPlugin" + ), // Macro implementation that performs the source transformation of a macro. .macro( name: "MacrosImplementation", dependencies: [ .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), .product(name: "SwiftCompilerPlugin", package: "swift-syntax") - ], - plugins: [.plugin(name: "SwiftLintPlugin")] + ] ), // Library that exposes a macro as part of its API, which is used in client programs. .target( name: "Macros", - dependencies: ["MacrosImplementation"], - plugins: [.plugin(name: "SwiftLintPlugin")] + dependencies: ["MacrosImplementation"] ), .testTarget( name: "MacrosTests", @@ -53,8 +55,7 @@ let package = Package( "MacrosImplementation", .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), .product(name: "MacroTesting", package: "swift-macro-testing"), - ], - plugins: [.plugin(name: "SwiftLintPlugin")] + ] ), ] ) diff --git a/Plugins/SwiftLintPlugin/PathExtension.swift b/Plugins/SwiftLintPlugin/PathExtension.swift index 0a0a44a..568cae8 100644 --- a/Plugins/SwiftLintPlugin/PathExtension.swift +++ b/Plugins/SwiftLintPlugin/PathExtension.swift @@ -17,7 +17,94 @@ // import Foundation + +#if canImport(PackagePlugin) import PackagePlugin +#else +public struct Path: Hashable { + + let string: String + + var stringValue: String { string } + + init(_ string: String) { + self.string = string + } + + /// The last path component (without any extension). + public var stem: String { + let filename = self.lastComponent + if let ext = self.extension { + return String(filename.dropLast(ext.count + 1)) + } else { + return filename + } + } + + var lastComponent: String { + (string as NSString).lastPathComponent + } + + var `extension`: String? { + let ext = (string as NSString).pathExtension + if ext.isEmpty { return nil } + return ext + } + + func removingLastComponent() -> Path { + Path((string as NSString).deletingLastPathComponent) + } + + func appending(subpath: String) -> Path { + return Path(string + (string.hasSuffix("/") ? "" : "/") + subpath) + } + + func appending(_ components: [String]) -> Path { + return self.appending(subpath: components.joined(separator: "/")) + } + + func appending(_ components: String...) -> Path { + return self.appending(components) + } + + func appending(_ path: Path) -> Path { + return appending(subpath: path.string) + } + +} + +extension Path: CustomStringConvertible { + + @available(_PackageDescription, deprecated: 6.0) + public var description: String { + return self.string + } +} + +extension Path: Codable { + + @available(_PackageDescription, deprecated: 6.0) + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.string) + } + + @available(_PackageDescription, deprecated: 6.0) + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + self.init(string) + } +} + +public extension String.StringInterpolation { + + @available(_PackageDescription, deprecated: 6.0) + mutating func appendInterpolation(_ path: Path) { + self.appendInterpolation(path.string) + } +} +#endif extension Path { @@ -70,4 +157,31 @@ extension Path { URL(fileURLWithPath: self.string) } + var isAbsolute: Bool { + string.hasPrefix("/") + } + + var exists: Bool { + return FileManager.default.fileExists(atPath: string) + } + + var isDirectory: Bool { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: string, isDirectory: &isDirectory) else { return false } + return isDirectory.boolValue + } + + func getDirectoryContents(filter: (Path) throws -> Bool = { _ in true }) rethrows -> [Path] { + var files: [Path] = [] + let fileManager = FileManager.default + + guard let enumerator = fileManager.enumerator(atPath: self.string) else { return files } + + for case let file as String in enumerator where try filter(Path(file)) { + files.append(Path(file)) + } + + return files + } + } diff --git a/Plugins/SwiftLintPlugin/ProcessExtension.swift b/Plugins/SwiftLintPlugin/ProcessExtension.swift new file mode 100644 index 0000000..badc955 --- /dev/null +++ b/Plugins/SwiftLintPlugin/ProcessExtension.swift @@ -0,0 +1,54 @@ +// +// ProcessExtension.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +// Contains extensions for standalone swiftlint tool to run build system plugin code +#if !canImport(PackagePlugin) + +extension Process { + + convenience init(_ command: String, _ args: [String], workDirectory: Path? = nil) { + self.init() + self.executableURL = URL(fileURLWithPath: command) + self.arguments = args + if let workDirectory = workDirectory { + self.currentDirectoryURL = workDirectory.url + } + } + + func executeCommand() throws -> String { + let pipe = Pipe() + self.standardOutput = pipe + try self.run() + + let data = try pipe.fileHandleForReading.readToEnd() ?? Data() + guard let output = String(data: data, encoding: .utf8) else { + throw CocoaError(.fileReadUnknownStringEncoding, userInfo: [NSLocalizedDescriptionKey: "could not decode data \(data.base64EncodedString())"]) + } + + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func which(_ commandName: String) -> Process { + Process("/usr/bin/which", [commandName]) + } + +} + +#endif diff --git a/Plugins/SwiftLintPlugin/SwiftLintPlugin.swift b/Plugins/SwiftLintPlugin/SwiftLintPlugin.swift index c816954..0c4e741 100644 --- a/Plugins/SwiftLintPlugin/SwiftLintPlugin.swift +++ b/Plugins/SwiftLintPlugin/SwiftLintPlugin.swift @@ -17,37 +17,30 @@ // import Foundation + +#if canImport(PackagePlugin) +// Swift Package Plugin or Xcode Build Plugin import PackagePlugin -@main -struct SwiftLintPlugin: BuildToolPlugin { +extension SwiftLintPlugin: BuildToolPlugin {} - func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { - // disable output for SPM modules built in RELEASE mode - guard let target = target as? SourceModuleTarget else { - assertionFailure("invalid target") - return [] - } +#if canImport(XcodeProjectPlugin) +// Xcode Build Plugin +import XcodeProjectPlugin +extension SwiftLintPlugin: XcodeBuildToolPlugin {} +#endif - guard (target as? SwiftSourceModuleTarget)?.compilationConditions.contains(.debug) != false || target.kind == .test else { - print("SwiftLint: \(target.name): Skipping for RELEASE build") - return [] - } +#else - let inputFiles = target.sourceFiles(withSuffix: "swift").map(\.path) - guard !inputFiles.isEmpty else { - print("SwiftLint: \(target.name): No input files") - return [] - } +// Standalone tool +import ArgumentParser +import XcodeEditor - return try createBuildCommands( - target: target.name, - inputFiles: inputFiles, - packageDirectory: context.package.directory.firstParentContainingConfigFile() ?? context.package.directory, - workingDirectory: context.pluginWorkDirectory, - tool: context.tool(named:) - ) - } +extension SwiftLintPlugin: ParsableCommand {} +#endif + +@main +struct SwiftLintPlugin { // swiftlint:disable function_body_length private func createBuildCommands( @@ -66,13 +59,17 @@ struct SwiftLintPlugin: BuildToolPlugin { let cacheURL = URL(fileURLWithPath: workingDirectory.appending("cache.json").string) let outputPath = workingDirectory.appending("output.txt").string - // if clean build: clear cache +#if canImport(PackagePlugin) let buildDir = workingDirectory.removingLastComponent() // BrowserServicesKit .removingLastComponent() // browserserviceskit.output .removingLastComponent() // plugins .removingLastComponent() // SourcePackages .removingLastComponent() // DerivedData/DuckDuckGo-xxxx .appending("Build") +#else + let buildDir = pluginContext.buildRoot.removingLastComponent() +#endif + // if clean build: clear cache if let buildDirContents = try? fm.contentsOfDirectory(atPath: buildDir.string), !buildDirContents.contains("Products") { print("SwiftLint: \(target): Clean Build") @@ -111,11 +108,11 @@ struct SwiftLintPlugin: BuildToolPlugin { } // merge diagnostics from last linter pass into cache - for outputLint in lastOutput.split(separator: "\n") { - guard let filePath = outputLint.split(separator: ":", maxSplits: 1).first.map(String.init), + for outputLine in lastOutput.split(separator: "\n") { + guard let filePath = outputLine.split(separator: ":", maxSplits: 1).first.map(String.init), !filesToProcess.contains(filePath) else { continue } - newCache[filePath]?.appendDiagnosticsMessage(String(outputLint)) + newCache[filePath]?.appendDiagnosticsMessage(String(outputLine)) } // collect cached diagnostic messages from cache @@ -153,7 +150,7 @@ struct SwiftLintPlugin: BuildToolPlugin { ] } else { - print("SwiftLint: \(target): No new files to process") + print("🤷‍♂️ SwiftLint: \(target): No new files to process") try JSONEncoder().encode(newCache).write(to: cacheURL) try "".write(toFile: outputPath, atomically: false, encoding: .utf8) } @@ -186,13 +183,128 @@ struct SwiftLintPlugin: BuildToolPlugin { } // swiftlint:enable function_body_length + // MARK: - Standalone tool Main +#if !canImport(PackagePlugin) + + private func getModifiedFiles(at path: Path) throws -> [Path] { + // path to `git` + let git = try { + let cacheURL = pluginContext.pluginWorkDirectory.appending("git").url + if let cached = try? String(contentsOf: cacheURL) { return cached } + let whichGit = Process.which("git") + print("no chached git, running `\(whichGit.executableURL!.path) git`") + let git = try whichGit.executeCommand() + try? git.write(to: cacheURL, atomically: false, encoding: .utf8) + return git + }() + + print("Running \(git) diff at \(path)") + let output = try Process(git, ["diff", "HEAD", "--name-only"], workDirectory: path) + .executeCommand() + + return output.components(separatedBy: "\n").filter { !$0.isEmpty }.map{ + path.appending(subpath: $0) // absolute path + } + } + + mutating func run() throws { + let target: XcodeTarget + let start = Date() + + let gitRootFolders: [Path] = try { + struct ProjectCache: Codable { + let projectModified: Date + let gitRootFolders: [Path] + } + // try loading list of .git root folders from cache if pbxproj is not modified + let cacheURL = URL(fileURLWithPath: pluginContext.pluginWorkDirectory.appending("project_cache.json").string) + + let projectModified = try pluginContext.xcodeProject.filePath.appending("project.pbxproj").modified + if let cache = (try? JSONDecoder().decode(ProjectCache.self, from: Data(contentsOf: cacheURL))), + cache.projectModified == projectModified { + return cache.gitRootFolders + } + + // load xc project + let project = XCProjectWithCachedGroups(filePath: pluginContext.xcodeProject.filePath.string)! + let projectFiles = project.files() ?? [] + + // get all folders with `.git` subfolder (like BrowserServicesKit) from xc project build files + let gitRootFolders = projectFiles.compactMap { sourceFile in + let path = sourceFile.path + guard path.isDirectory && path.appending(subpath: ".git").exists else { return nil } + return path + } + [pluginContext.xcodeProject.directory] // and project root itself + + // cache + let cache = ProjectCache(projectModified: projectModified, gitRootFolders: gitRootFolders) + try JSONEncoder().encode(cache).write(to: cacheURL) + + return gitRootFolders + }() + + // get all modified files + var buildFiles = Set() + for gitRootFolder in gitRootFolders { + let modifiedFiles = try getModifiedFiles(at: gitRootFolder) + buildFiles.formUnion(modifiedFiles.map { BuildFile(path: $0, type: .source) }) + } + + target = FakeTarget(displayName: "Target", files: buildFiles) + let time = Date().timeIntervalSince(start) + print("⏰ files parsing took \(String(format: "%.2f", time))s.") + + let commands = try createBuildCommands(context: pluginContext, target: target) + + for command in commands { + switch command { + case .prebuildCommand(displayName: let name, executable: let path, arguments: let args, outputFilesDirectory: _): + print("Running \(name)") + let process = Process(path.string, args) + try process.run() + process.waitUntilExit() + } + } + } +#endif + } -#if canImport(XcodeProjectPlugin) +// MARK: - Swift Package Plugin +#if canImport(PackagePlugin) +extension SwiftLintPlugin { + func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { + // disable output for SPM modules built in RELEASE mode + guard let target = target as? SourceModuleTarget else { + assertionFailure("invalid target") + return [] + } -import XcodeProjectPlugin + guard (target as? SwiftSourceModuleTarget)?.compilationConditions.contains(.debug) != false || target.kind == .test else { + print("SwiftLint: \(target.name): Skipping for RELEASE build") + return [] + } + + let inputFiles = target.sourceFiles(withSuffix: "swift").map(\.path) + guard !inputFiles.isEmpty else { + print("SwiftLint: \(target.name): No input files") + return [] + } + + return try createBuildCommands( + target: target.name, + inputFiles: inputFiles, + packageDirectory: context.package.directory.firstParentContainingConfigFile() ?? context.package.directory, + workingDirectory: context.pluginWorkDirectory, + tool: context.tool(named:) + ) + } +} +#endif -extension SwiftLintPlugin: XcodeBuildToolPlugin { +// MARK: - Xcode Build Plugin and standalone tool launcher +#if canImport(XcodeProjectPlugin) || !canImport(PackagePlugin) +extension SwiftLintPlugin { func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] { let inputFiles = target.inputFiles.filter { $0.type == .source && $0.path.extension == "swift" diff --git a/Plugins/SwiftLintPlugin/SwiftLintToolExtensions.swift b/Plugins/SwiftLintPlugin/SwiftLintToolExtensions.swift new file mode 100644 index 0000000..e25df2b --- /dev/null +++ b/Plugins/SwiftLintPlugin/SwiftLintToolExtensions.swift @@ -0,0 +1,256 @@ +// +// SwiftLintToolExtensions.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +// Contains extensions for standalone swiftlint tool to run build system plugin code +#if !canImport(PackagePlugin) + +import Foundation +import XcodeEditor + +/// Maps XcodeEditor productType to Build Plugin FileType +enum TargetKind: String { + case test + case main + + init(productType: String) { + if productType.hasSuffix("test") || productType.hasSuffix("testing") { + self = .test + } else { + self = .main + } + } + +} + +/// Maps XcodeEditor XcodeSourceFileType to Build Plugin FileType +public enum FileType: Equatable { + case source + case header + case resource + case unknown + + // swiftlint:disable:next cyclomatic_complexity + init(_ type: XcodeSourceFileType) { + self = switch type { + case .Framework: .unknown + case .PropertyList: .resource + case .SourceCodeHeader: .header + case .SourceCodeObjC: .source + case .SourceCodeObjCPlusPlus: .source + case .SourceCodeCPlusPlus: .source + case .XibFile: .resource + case .ImageResourcePNG: .resource + case .Bundle: .resource + case .Archive: .resource + case .HTML: .resource + case .TEXT: .resource + case .XcodeProject: .unknown + case .Folder: .unknown + case .AssetCatalog: .resource + case .SourceCodeSwift: .source + case .Application: .unknown + case .Playground: .unknown + case .ShellScript: .unknown + case .Markdown: .resource + case .XMLPropertyList: .resource + case .Storyboard: .resource + case .XCConfig: .unknown + case .XCDataModel: .resource + case .LocalizableStrings: .resource + default: .unknown + } + } +} + +protocol File { + var path: Path { get } + var type: FileType { get } +} + +/// More effective XCSourceFile path construction with file.key->parent-group map +final class XCProjectWithCachedGroups: XCProject { + + private var _groupsByMemberKey: [String: XCGroup]? + var groupsByMemberKey: [String: XCGroup] { + if let key = _groupsByMemberKey { return key } + + var groupsByMemberKey = [String: XCGroup]() + for group in groups() ?? [] { + for key in group.children { + groupsByMemberKey[(key as? String)!] = group + } + } + _groupsByMemberKey = groupsByMemberKey + return groupsByMemberKey + } + + override func groupForGroupMember(withKey key: String!) -> XCGroup! { + return groupsByMemberKey[key] + } + +} + +/// Maps XcodeEditor XCSourceFile to Build Plugin File +extension XCSourceFile: File { + + var path: Path { + let path = if let name, name.contains("/") { + Path(name) + } else { + Path(pathRelativeToProjectRoot() ?? value(forKey: "_path") as? String ?? name) + } + return path.isAbsolute ? path : pluginContext.xcodeProject.directory.appending(path) + } + + var type: FileType { + FileType(XcodeSourceFileType(rawValue: (value(forKey: "_type") as? NSNumber)?.intValue ?? 0)) + } + +} + +protocol XcodeTarget { + var displayName: String { get } + + var kind: TargetKind { get } + var inputFiles: [File] { get } + func sourceFiles(withSuffix suffix: String) -> [File] +} + +extension XcodeTarget { + func sourceFiles(withSuffix suffix: String) -> [File] { + inputFiles.filter { + $0.path.string.hasSuffix(suffix) + } + } +} + +/// Maps XcodeEditor XCTarget to Build Plugin XcodeTarget +extension XCTarget: XcodeTarget { + + var displayName: String { + name + } + + var kind: TargetKind { + TargetKind(productType: productType) + } + + var sourceFiles: [XCSourceFile] { + members().compactMap { $0 as? XCSourceFile } + } + + var inputFiles: [File] { + sourceFiles as [File] + } + +} + +struct FakeTarget: XcodeTarget { + var displayName: String + var kind: TargetKind = .main + var files: Set = [] + var inputFiles: [File] { + Array(files) + } +} + +struct BuildFile: Hashable, File { + var path: Path + var type: FileType + + init(path: Path, type: FileType) { + self.path = path + self.type = type + } + + init(sourceFile: XCSourceFile) { + self.init(path: sourceFile.path, type: sourceFile.type) + } +} + +enum Command { + case prebuildCommand( + displayName: String, + executable: Path, + arguments: [String], + outputFilesDirectory: Path + ) +} + +struct Project { + var filePath: Path + var directory: Path +} + +struct PluginContext { + + struct Tool { + var path: Path + } + + let processInfo: ProcessInfo = ProcessInfo() + + let xcodeProject: Project + + var buildRoot: Path { + Path(processInfo.environment["BUILD_ROOT"]!) + } + var derivedData: Path { + buildRoot.removingLastComponent().removingLastComponent() + } + var packageArtifacts: Path { + derivedData.appending(["SourcePackages", "artifacts"]) + } + + var pluginWorkDirectory: Path { + let path = Path(ProcessInfo().arguments[0] + "_files") + if !FileManager.default.fileExists(atPath: path.string) { + try! FileManager.default.createDirectory(atPath: path.string, withIntermediateDirectories: false) // swiftlint:disable:this force_try + } + return path + } + + init() { + xcodeProject = Project(filePath: Path(processInfo.environment["PROJECT_FILE_PATH"]!), + directory: Path(processInfo.environment["PROJECT_DIR"]!)) + } + + func tool(named name: String) -> Tool { + // SourcePackages/artifacts/apple-toolbox/SwiftLintBinary/SwiftLintBinary.artifactbundle/swiftlint-0.54.0-macos/bin/swiftlint + guard name == "swiftlint" else { + fatalError("Unknown tool: `\(name)`") + } + var path = packageArtifacts.appending(["apple-toolbox", "SwiftLintBinary", "SwiftLintBinary.artifactbundle"]) + let fm = FileManager.default + + // swiftlint:disable:next force_try + let swiftlintFolder = try! fm.contentsOfDirectory(atPath: path.string).first(where: { + var isFolder: ObjCBool = false + return $0.hasPrefix("swiftlint") && fm.fileExists(atPath: path.appending(subpath: $0).string, isDirectory: &isFolder) && isFolder.boolValue + })! + path = path.appending([swiftlintFolder, "bin", "swiftlint"]) + + return Tool(path: path) + } + +} +typealias XcodePluginContext = PluginContext + +let pluginContext = PluginContext() + +#endif