diff --git a/Package.resolved b/Package.resolved index fe93799..ac8aff4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,43 +1,42 @@ { - "object": { - "pins": [ - { - "package": "CommandLineKit", - "repositoryURL": "https://github.com/benoit-pereira-da-silva/CommandLine.git", - "state": { - "branch": null, - "revision": "3eaafd5941e359f025a411e3b5947f96d82d1bc9", - "version": "4.0.9" - } - }, - { - "package": "PathKit", - "repositoryURL": "https://github.com/kylef/PathKit.git", - "state": { - "branch": null, - "revision": "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", - "version": "1.0.1" - } - }, - { - "package": "Rainbow", - "repositoryURL": "https://github.com/onevcat/Rainbow.git", - "state": { - "branch": null, - "revision": "797a68d0a642609424b08f11eb56974a54d5f6e2", - "version": "3.1.4" - } - }, - { - "package": "Spectre", - "repositoryURL": "https://github.com/kylef/Spectre.git", - "state": { - "branch": null, - "revision": "26cc5e9ae0947092c7139ef7ba612e34646086c7", - "version": "0.10.1" - } + "originHash" : "aee58651e5dc9820a40e70830abc3a55148f88c2a93e3e431834ee554a05fd3d", + "pins" : [ + { + "identity" : "pathkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/PathKit.git", + "state" : { + "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", + "version" : "1.0.1" } - ] - }, - "version": 1 + }, + { + "identity" : "rainbow", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Rainbow.git", + "state" : { + "revision" : "797a68d0a642609424b08f11eb56974a54d5f6e2", + "version" : "3.1.4" + } + }, + { + "identity" : "spectre", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/Spectre.git", + "state" : { + "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", + "version" : "0.10.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + } + ], + "version" : 3 } diff --git a/Package.swift b/Package.swift index c03cb86..676d556 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/onevcat/Rainbow.git", from: "3.1.1"), - .package(url: "https://github.com/benoit-pereira-da-silva/CommandLine.git", from: "4.0.0"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.4.0"), .package(url: "https://github.com/kylef/PathKit.git", from: "1.0.1") ], targets: [ @@ -20,9 +20,10 @@ let package = Package( name: "FengNiao", dependencies: [ "FengNiaoKit", - .product(name: "CommandLineKit", package: "CommandLine") + .product(name: "ArgumentParser", package: "swift-argument-parser") ] ), - .testTarget(name: "FengNiaoKitTests", dependencies: ["FengNiaoKit"], exclude: ["../Fixtures"]) + .testTarget(name: "FengNiaoKitTests", dependencies: ["FengNiaoKit"], exclude: ["../Fixtures"]), + .testTarget(name: "FengNiaoCLITests", dependencies: ["FengNiao"]) ] ) diff --git a/Sources/FengNiao/CLI.swift b/Sources/FengNiao/CLI.swift new file mode 100644 index 0000000..bfaf28b --- /dev/null +++ b/Sources/FengNiao/CLI.swift @@ -0,0 +1,161 @@ +import ArgumentParser +import Foundation +import Rainbow +import FengNiaoKit +import PathKit + +@main +struct FengNiaoCommand: ParsableCommand { + private static let appVersion = "0.11.0" + private static let exitUnusedResources: Int32 = 1 + private static let exitUsage: Int32 = 64 + + static let configuration = CommandConfiguration( + commandName: "fengniao", + abstract: "Find and delete unused resources in Xcode projects.", + version: appVersion + ) + + @Option( + name: .shortAndLong, + help: "Root path of your Xcode project. Default is current folder." + ) + var project: String = "." + + @Flag( + name: .long, + help: "Delete the found unused files without asking." + ) + var force: Bool = false + + @Option( + name: [.short, .long], + parsing: .upToNextOption, + help: "Exclude paths from search." + ) + var exclude: [String] = [] + + @Option( + name: [.short, .long], + parsing: .upToNextOption, + help: "Resource file extensions need to be searched. Default is 'imageset jpg png gif pdf'" + ) + var resourceExtensions: [String] = ["imageset", "jpg", "png", "gif", "pdf"] + + @Option( + name: [.short, .long], + parsing: .upToNextOption, + help: "In which types of files we should search for resource usage. Default is 'm mm swift xib storyboard plist'" + ) + var fileExtensions: [String] = ["h", "m", "mm", "swift", "xib", "storyboard", "plist"] + + @Flag( + name: .long, + help: "Skip the Project file (.pbxproj) reference cleaning. By skipping it, the project file will be left untouched. You may want to skip ths step if you are trying to build multiple projects with dependency and keep .pbxproj unchanged while compiling." + ) + var skipProjReference: Bool = false + + @Flag( + name: .long, + help: "Print results as xcode warnings and return non zero code if any." + ) + var xcodeWarnings: Bool = false + + @Flag( + name: .long, + help: "List unused files and exit without prompting." + ) + var listOnly: Bool = false + + func run() throws { + let fengNiao = FengNiao( + projectPath: project, + excludedPaths: exclude, + resourceExtensions: resourceExtensions, + searchInFileExtensions: fileExtensions + ) + + let unusedFiles: [FileInfo] + do { + print("Searching unused file. This may take a while...") + unusedFiles = try fengNiao.unusedFiles() + } catch { + guard let e = error as? FengNiaoError else { + print("Unknown Error: \(error)".red.bold) + throw ExitCode(Self.exitUsage) + } + switch e { + case .noResourceExtension: + print("You need to specify some resource extensions as search target. Use --resource-extensions to specify.".red.bold) + case .noFileExtension: + print("You need to specify some file extensions to search in. Use --file-extensions to specify.".red.bold) + } + throw ExitCode(Self.exitUsage) + } + + if unusedFiles.isEmpty { + print("😎 Hu, you have no unused resources in path: \(Path(project).absolute()).".green.bold) + return + } + + if xcodeWarnings { + for file in unusedFiles.sorted(by: { $0.size > $1.size }) { + print("\(file.path.string): warning: Unused resource of size \(file.readableSize)") + } + throw ExitCode(Self.exitUnusedResources) + } + + if listOnly { + let size = unusedFiles.reduce(0) { $0 + $1.size }.fn_readableSize + for file in unusedFiles.sorted(by: { $0.size > $1.size }) { + print("\(file.readableSize) \(file.path.string)") + } + print("\(unusedFiles.count) unused files are found. Total Size: \(size)".yellow.bold) + return + } + + if !force { + var result = promptResult(files: unusedFiles) + while result == .list { + for file in unusedFiles.sorted(by: { $0.size > $1.size }) { + print("\(file.readableSize) \(file.path.string)") + } + result = promptResult(files: unusedFiles) + } + + switch result { + case .list: + fatalError() + case .delete: + break + case .ignore: + print("Ignored. Nothing to do, bye!".green.bold) + return + } + } + + print("Deleting unused files...⚙".bold) + + let (deleted, failed) = FengNiao.delete(unusedFiles) + guard failed.isEmpty else { + print("\(unusedFiles.count - failed.count) unused files are deleted. But we encountered some error while deleting these \(failed.count) files:".yellow.bold) + for (fileInfo, err) in failed { + print("\(fileInfo.path.string) - \(err.localizedDescription)") + } + throw ExitCode(Self.exitUsage) + } + + print("\(unusedFiles.count) unused files are deleted.".green.bold) + + if !skipProjReference { + if let children = try? Path(project).absolute().children() { + print("Now Deleting unused Reference in project.pbxproj...⚙".bold) + for path in children where path.lastComponent.hasSuffix("xcodeproj") { + let pbxproj = path + "project.pbxproj" + FengNiao.deleteReference(projectFilePath: pbxproj, deletedFiles: deleted) + } + print("Unused Reference deleted successfully.".green.bold) + } + } + } +} diff --git a/Sources/FengNiao/main.swift b/Sources/FengNiao/main.swift deleted file mode 100644 index f5f9740..0000000 --- a/Sources/FengNiao/main.swift +++ /dev/null @@ -1,216 +0,0 @@ -// -// main.swift -// FengNiao -// -// Created by WANG WEI on 2017/3/7. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - - -import Foundation -import CommandLineKit -import Rainbow -import FengNiaoKit -import PathKit - -let appVersion = "0.11.0" - -#if os(Linux) -let EX_OK: Int32 = 0 -let EX_USAGE: Int32 = 64 -#endif - -let EXIT_UNUSED_RESOURCES: Int32 = 1 - -let cli = CommandLineKit.CommandLine() -cli.formatOutput = { s, type in - var str: String - switch(type) { - case .error: str = s.red.bold - case .optionFlag: str = s.green.underline - default: str = s - } - - return cli.defaultFormat(s: str, type: type) -} - -let projectPathOption = StringOption( - shortFlag: "p", longFlag: "project", - helpMessage: "Root path of your Xcode project. Default is current folder.") -cli.addOption(projectPathOption) - -let isForceOption = BoolOption( - longFlag: "force", - helpMessage: "Delete the found unused files without asking.") -cli.addOption(isForceOption) - -let excludePathOption = MultiStringOption( - shortFlag: "e", longFlag: "exclude", - helpMessage: "Exclude paths from search.") -cli.addOption(excludePathOption) - -let resourceExtOption = MultiStringOption( - shortFlag: "r", longFlag: "resource-extensions", - helpMessage: "Resource file extensions need to be searched. Default is 'imageset jpg png gif pdf'") -cli.addOption(resourceExtOption) - -let fileExtOption = MultiStringOption( - shortFlag: "f", longFlag: "file-extensions", - helpMessage: "In which types of files we should search for resource usage. Default is 'm mm swift xib storyboard plist'") -cli.addOption(fileExtOption) - -let skipProjRefereceCleanOption = BoolOption( - longFlag: "skip-proj-reference", - helpMessage: "Skip the Project file (.pbxproj) reference cleaning. By skipping it, the project file will be left untouched. You may want to skip ths step if you are trying to build multiple projects with dependency and keep .pbxproj unchanged while compiling." -) -cli.addOption(skipProjRefereceCleanOption) - -let xcodeWarningsOption = BoolOption(longFlag: "xcode-warnings", helpMessage: "Print results as xcode warnings and return non zero code if any.") -cli.addOption(xcodeWarningsOption) - -let listOnlyOption = BoolOption(longFlag: "list-only", helpMessage: "List unused files and exit without prompting.") -cli.addOption(listOnlyOption) - -let versionOption = BoolOption(longFlag: "version", helpMessage: "Print version.") -cli.addOption(versionOption) - -let helpOption = BoolOption(shortFlag: "h", longFlag: "help", - helpMessage: "Print this help message.") -cli.addOption(helpOption) - -do { - try cli.parse() -} catch { - cli.printUsage(error) - exit(EX_USAGE) -} - -if !cli.unparsedArguments.isEmpty { - print("Unknow arguments: \(cli.unparsedArguments)".red) - cli.printUsage() - exit(EX_USAGE) -} - -if helpOption.value { - cli.printUsage() - exit(EX_OK) -} - -if versionOption.value { - print(appVersion) - exit(EX_OK); -} - - -let projectPath = projectPathOption.value ?? "." -let isForce = isForceOption.value -let excludePaths = excludePathOption.value ?? [] -let resourceExtentions = resourceExtOption.value ?? ["imageset", "jpg", "png", "gif", "pdf"] -let fileExtensions = fileExtOption.value ?? ["h", "m", "mm", "swift", "xib", "storyboard", "plist"] - -let fengNiao = FengNiao(projectPath: projectPath, - excludedPaths: excludePaths, - resourceExtensions: resourceExtentions, - searchInFileExtensions: fileExtensions) - -let unusedFiles: [FileInfo] -do { - print("Searching unused file. This may take a while...") - unusedFiles = try fengNiao.unusedFiles() -} catch { - guard let e = error as? FengNiaoError else { - print("Unknown Error: \(error)".red.bold) - exit(EX_USAGE) - } - switch e { - case .noResourceExtension: - print("You need to specify some resource extensions as search target. Use --resource-extensions to specify.".red.bold) - case .noFileExtension: - print("You need to specify some file extensions to search in. Use --file-extensions to specify.".red.bold) - } - exit(EX_USAGE) -} - -if unusedFiles.isEmpty { - print("😎 Hu, you have no unused resources in path: \(Path(projectPath).absolute()).".green.bold) - exit(EX_OK) -} - -if xcodeWarningsOption.value { - for file in unusedFiles.sorted(by: { $0.size > $1.size }) { - print("\(file.path.string): warning: Unused resource of size \(file.readableSize)") - } - exit(EXIT_UNUSED_RESOURCES); -} - -if listOnlyOption.value { - let size = unusedFiles.reduce(0) { $0 + $1.size }.fn_readableSize - for file in unusedFiles.sorted(by: { $0.size > $1.size }) { - print("\(file.readableSize) \(file.path.string)") - } - print("\(unusedFiles.count) unused files are found. Total Size: \(size)".yellow.bold) - exit(EX_OK) -} - -if !isForce { - var result = promptResult(files: unusedFiles) - while result == .list { - for file in unusedFiles.sorted(by: { $0.size > $1.size }) { - print("\(file.readableSize) \(file.path.string)") - } - result = promptResult(files: unusedFiles) - } - - switch result { - case .list: - fatalError() - case .delete: - break - case .ignore: - print("Ignored. Nothing to do, bye!".green.bold) - exit(EX_OK) - } -} - -print("Deleting unused files...⚙".bold) - -let (deleted, failed) = FengNiao.delete(unusedFiles) -guard failed.isEmpty else { - print("\(unusedFiles.count - failed.count) unused files are deleted. But we encountered some error while deleting these \(failed.count) files:".yellow.bold) - for (fileInfo, err) in failed { - print("\(fileInfo.path.string) - \(err.localizedDescription)") - } - exit(EX_USAGE) -} - - -print("\(unusedFiles.count) unused files are deleted.".green.bold) - -if !skipProjRefereceCleanOption.value { - if let children = try? Path(projectPath).absolute().children(){ - print("Now Deleting unused Reference in project.pbxproj...⚙".bold) - for path in children { - if path.lastComponent.hasSuffix("xcodeproj"){ - let pbxproj = path + "project.pbxproj" - FengNiao.deleteReference(projectFilePath: pbxproj, deletedFiles: deleted) - } - } - print("Unused Reference deleted successfully.".green.bold) - } -} diff --git a/Tests/FengNiaoCLITests/CLIParsingTests.swift b/Tests/FengNiaoCLITests/CLIParsingTests.swift new file mode 100644 index 0000000..bcb6dc1 --- /dev/null +++ b/Tests/FengNiaoCLITests/CLIParsingTests.swift @@ -0,0 +1,60 @@ +import Testing +@testable import FengNiao + +@Suite("CLI Parsing") +struct CLIParsingTests { + @Test("defaults are applied") + func defaultsAreApplied() throws { + let command = try FengNiaoCommand.parse([]) + #expect(command.project == ".") + #expect(command.force == false) + #expect(command.exclude.isEmpty) + #expect(command.resourceExtensions == ["imageset", "jpg", "png", "gif", "pdf"]) + #expect(command.fileExtensions == ["h", "m", "mm", "swift", "xib", "storyboard", "plist"]) + #expect(command.skipProjReference == false) + #expect(command.xcodeWarnings == false) + #expect(command.listOnly == false) + } + + @Test("parses simple flags") + func parsesSimpleFlags() throws { + let command = try FengNiaoCommand.parse([ + "--force", + "--skip-proj-reference", + "--xcode-warnings", + "--list-only" + ]) + #expect(command.force) + #expect(command.skipProjReference) + #expect(command.xcodeWarnings) + #expect(command.listOnly) + } + + @Test("parses options with values") + func parsesOptionsWithValues() throws { + let command = try FengNiaoCommand.parse([ + "--project", "/tmp/project", + "--exclude", "Carthage", "Pods", + "--resource-extensions", "png", "jpg", + "--file-extensions", "swift", "xib" + ]) + #expect(command.project == "/tmp/project") + #expect(command.exclude == ["Carthage", "Pods"]) + #expect(command.resourceExtensions == ["png", "jpg"]) + #expect(command.fileExtensions == ["swift", "xib"]) + } + + @Test("parses short flags") + func parsesShortFlags() throws { + let command = try FengNiaoCommand.parse([ + "-p", "./Sample", + "-e", "A", "B", + "-r", "png", + "-f", "swift" + ]) + #expect(command.project == "./Sample") + #expect(command.exclude == ["A", "B"]) + #expect(command.resourceExtensions == ["png"]) + #expect(command.fileExtensions == ["swift"]) + } +}