From 18d8b68e4a64b2ffd5d8b06a00bd62b67ecfea2b Mon Sep 17 00:00:00 2001 From: Geoffrey Foster Date: Fri, 7 Dec 2018 14:09:04 -0500 Subject: [PATCH] adding Xcode support for automatic linting adding the copy command --- Package.swift | 6 +- README.md | 3 + .../stringray/Commands/CommandRegistry.swift | 3 + Sources/stringray/Commands/CopyCommand.swift | 58 +++++++++++ Sources/stringray/Commands/LintCommand.swift | 97 +++++++++++++++---- Sources/stringray/Lint Rules/Linter.swift | 15 ++- .../MissingLocalizationLintRule.swift | 4 +- .../MissingPlaceholderLintRule.swift | 2 +- .../OrphanedLocalizationLintRule.swift | 2 +- Sources/stringray/main.swift | 1 + 10 files changed, 164 insertions(+), 27 deletions(-) create mode 100644 Sources/stringray/Commands/CopyCommand.swift diff --git a/Package.swift b/Package.swift index 49e156f..f626298 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,8 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-package-manager.git", from: "0.3.0"), .package(url: "https://github.com/jpsim/Yams.git", from: "1.0.1"), - .package(url: "https://github.com/scottrhoyt/SwiftyTextTable.git", from: "0.5.0") + .package(url: "https://github.com/scottrhoyt/SwiftyTextTable.git", from: "0.5.0"), + .package(url: "https://github.com/g-Off/XcodeProject.git", .branch("master")) ], targets: [ .target( @@ -14,7 +15,8 @@ let package = Package( dependencies: [ "Utility", "Yams", - "SwiftyTextTable" + "SwiftyTextTable", + "XcodeProject" ] ), .testTarget( diff --git a/README.md b/README.md index 30687f7..3c23a93 100644 --- a/README.md +++ b/README.md @@ -24,5 +24,8 @@ stringray lint -i /path/to/original.lproj/Table.strings stringray lint -l ```` +## Installing +If you have Homebrew installed then the tool is available via `brew install g-Off/tools/stringray` + ## Building Use `make` to build or `make xcode` to generate the Xcode project. diff --git a/Sources/stringray/Commands/CommandRegistry.swift b/Sources/stringray/Commands/CommandRegistry.swift index dac47e9..22b898a 100644 --- a/Sources/stringray/Commands/CommandRegistry.swift +++ b/Sources/stringray/Commands/CommandRegistry.swift @@ -34,12 +34,15 @@ struct CommandRegistry { do { let parsedArguments = try parse() try process(arguments: parsedArguments) + exit(EXIT_SUCCESS) } catch let error as ArgumentParserError { print(error.description) + exit(EXIT_FAILURE) } catch let error { print(error.localizedDescription) + exit(EXIT_FAILURE) } } diff --git a/Sources/stringray/Commands/CopyCommand.swift b/Sources/stringray/Commands/CopyCommand.swift new file mode 100644 index 0000000..72e86e7 --- /dev/null +++ b/Sources/stringray/Commands/CopyCommand.swift @@ -0,0 +1,58 @@ +// +// CopyCommand.swift +// stringray +// +// Created by Geoffrey Foster on 2018-12-05. +// + +import Foundation +import Utility + +struct CopyCommand: Command { + private struct Arguments { + var inputFile: Foundation.URL! + var outputFile: Foundation.URL! + var matching: [Match] = [] + } + let command: String = "copy" + let overview: String = "Copy keys matching the given pattern from one strings table to another." + + private let binder: ArgumentBinder + + init(parser: ArgumentParser) { + binder = ArgumentBinder() + let subparser = parser.add(subparser: command, overview: overview) + + let inputFile = subparser.add(positional: "inputFile", kind: PathArgument.self, optional: false, usage: "", completion: .filename) + let outputFile = subparser.add(positional: "outputFile", kind: PathArgument.self, optional: false, usage: "", completion: .filename) + let prefix = subparser.add(option: "--prefix", shortName: "-p", kind: [String].self, strategy: .oneByOne, usage: "", completion: nil) + + binder.bind(positional: inputFile) { (arguments, inputFile) in + arguments.inputFile = URL(fileURLWithPath: inputFile.path.asString) + } + binder.bind(positional: outputFile) { (arguments, outputFile) in + arguments.outputFile = URL(fileURLWithPath: outputFile.path.asString) + } + binder.bind(option: prefix) { (arguments, matching) in + arguments.matching = matching.map { + return .prefix($0) + } + } + } + + func run(with arguments: ArgumentParser.Result) throws { + var commandArgs = Arguments() + try binder.fill(parseResult: arguments, into: &commandArgs) + try copy(from: commandArgs.inputFile, to: commandArgs.outputFile, matching: commandArgs.matching) + } + + private func copy(from: Foundation.URL, to: Foundation.URL, matching: [Match]) throws { + let loader = StringsTableLoader() + let fromTable = try loader.load(url: from) + var toTable = try loader.load(url: to) + + let filteredTable = fromTable.withKeys(matching: matching) + toTable.addEntries(from: filteredTable) + try write(to: to.resourceDirectory, table: toTable) + } +} diff --git a/Sources/stringray/Commands/LintCommand.swift b/Sources/stringray/Commands/LintCommand.swift index d16f25c..04376cf 100644 --- a/Sources/stringray/Commands/LintCommand.swift +++ b/Sources/stringray/Commands/LintCommand.swift @@ -9,11 +9,19 @@ import Foundation import Utility import Basic import SwiftyTextTable +import XcodeProject struct LintCommand: Command { private struct Arguments { var inputFile: [AbsolutePath] = [] var listRules: Bool = false + var configFile: AbsolutePath? + } + + private struct LintInput: Hashable { + let resourceURL: Foundation.URL + let tableName: String + let locale: Locale } let command: String = "lint" @@ -35,6 +43,11 @@ struct LintCommand: Command { binder.bind(option: listRules) { (arguments, listRules) in arguments.listRules = listRules } + + let configFileOption = subparser.add(option: "--config", shortName: "-c", kind: PathArgument.self, usage: "Configuration YAML file", completion: .filename) + binder.bind(option: configFileOption) { (arguments, configFile) in + arguments.configFile = configFile.path + } } func run(with arguments: ArgumentParser.Result) throws { @@ -46,11 +59,70 @@ struct LintCommand: Command { return } + let lintInput: [LintInput] + var reporter: Reporter = ConsoleReporter() if commandArgs.inputFile.isEmpty { - commandArgs.inputFile = [AbsolutePath(FileManager.default.currentDirectoryPath)] + let environment = ProcessInfo.processInfo.environment + if let xcodeInput = try inputsFromXcode(environment: environment) { + lintInput = xcodeInput + reporter = XcodeReporter() + } else if let currentWorkingDirectory = localFileSystem.currentWorkingDirectory { + lintInput = inputs(from: [currentWorkingDirectory]) + } else { + lintInput = [] + } + } else { + lintInput = inputs(from: commandArgs.inputFile) } - try lint(files: commandArgs.inputFile) + try lint(inputs: lintInput, reporter: reporter) + } + + private func inputs(from files: [AbsolutePath]) -> [LintInput] { + let inputs: [LintInput] = files.filter { + localFileSystem.exists($0) && localFileSystem.isFile($0) + }.map { + URL(fileURLWithPath: $0.asString) + }.compactMap { + guard let tableName = $0.tableName else { return nil } + guard let locale = $0.locale else { return nil } + return LintInput(resourceURL: $0.resourceDirectory, tableName: tableName, locale: locale) + } + return inputs + } + + private func inputsFromXcode(environment: [String: String]) throws -> [LintInput]? { + guard let projectPath = environment["PROJECT_FILE_PATH"], + let targetName = environment["TARGETNAME"], + let infoPlistPath = environment["INFOPLIST_FILE"], + let sourceRoot = environment["SOURCE_ROOT"] else { + return nil + } + + let projectURL = URL(fileURLWithPath: projectPath) + let sourceRootURL = URL(fileURLWithPath: sourceRoot) + let infoPlistURL = URL(fileURLWithPath: infoPlistPath, relativeTo: sourceRootURL) + let data = try Data(contentsOf: infoPlistURL) + guard let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], let base = plist["CFBundleDevelopmentRegion"] as? String else { + return nil + } + let locale = Locale(identifier: base) + let projectFile = try ProjectFile(url: projectURL) + guard let target = projectFile.project.target(named: targetName) else { return nil } + guard let resourcesBuildPhase = target.resourcesBuildPhase else { return nil } + let variantGroups = resourcesBuildPhase.files.compactMap { $0.fileRef as? PBXVariantGroup }.filter { + guard let name = $0.name else { return false } + return name.hasSuffix(".strings") || name.hasSuffix(".stringsdict") + } + let variantGroupsFiles = variantGroups.flatMap { + $0.children.compactMap { $0 as? PBXFileReference }.compactMap { $0.url }.filter { $0.pathExtension == "strings" || $0.pathExtension == "stringsdict" } + } + let allInputs: [LintInput] = variantGroupsFiles.compactMap { + guard let tableName = $0.tableName else { return nil } + return LintInput(resourceURL: $0.resourceDirectory, tableName: tableName, locale: locale) + } + let uniqueInputs = Set(allInputs) + return Array(uniqueInputs) } private func listRules() { @@ -74,23 +146,14 @@ struct LintCommand: Command { print(table.render()) } - private func lint(files: [AbsolutePath]) throws { + private func lint(inputs: [LintInput], reporter: Reporter) throws { var loader = StringsTableLoader() loader.options = [.lineNumbers] - let linter = Linter(excluded: []) - - try files.forEach { - let url = URL(fileURLWithPath: $0.asString) - print("Linting: \(url.path)") - - var isDirectory: ObjCBool = false - let fileExists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) - if fileExists && !isDirectory.boolValue { - let table = try loader.load(url: url) - try linter.report(on: table, url: url) - } else { - print("Skipping: \(url.path) | this path is a directory or the file does not exist.") - } + let linter = Linter(reporter: reporter) + try inputs.forEach { + print("Linting: \($0.tableName)") + let table = try loader.load(url: $0.resourceURL, name: $0.tableName, base: $0.locale) + try linter.report(on: table, url: $0.resourceURL) } } } diff --git a/Sources/stringray/Lint Rules/Linter.swift b/Sources/stringray/Lint Rules/Linter.swift index 162847f..0291cc2 100644 --- a/Sources/stringray/Lint Rules/Linter.swift +++ b/Sources/stringray/Lint Rules/Linter.swift @@ -11,22 +11,26 @@ import Yams struct Linter { static let fileName = ".stringray.yml" - private static let rules: [LintRule] = [ + static let allRules: [LintRule] = [ MissingLocalizationLintRule(), OrphanedLocalizationLintRule(), MissingPlaceholderLintRule() ] + private enum LintError: Swift.Error { + case violations + } + let rules: [LintRule] let reporter: Reporter - init(rules: [LintRule], reporter: Reporter = ConsoleReporter()) { + init(rules: [LintRule] = Linter.allRules, reporter: Reporter = ConsoleReporter()) { self.rules = rules self.reporter = reporter } init(excluded: Set = []) { - let rules = Linter.rules.filter { + let rules = Linter.allRules.filter { !excluded.contains($0.info.identifier) } self.init(rules: rules) @@ -41,7 +45,7 @@ struct Linter { self.init(excluded: Set(excluded)) } - func run(on table: StringsTable, url: URL) throws -> [LintRuleViolation] { + private func run(on table: StringsTable, url: URL) throws -> [LintRuleViolation] { return try rules.flatMap { try $0.scan(table: table, url: url) } @@ -51,6 +55,9 @@ struct Linter { let violations = try run(on: table, url: url) var outputStream = LinterOutputStream(fileHandle: FileHandle.standardOutput) reporter.generateReport(for: violations, to: &outputStream) + if !violations.isEmpty { + throw LintError.violations + } } } diff --git a/Sources/stringray/Lint Rules/MissingLocalizationLintRule.swift b/Sources/stringray/Lint Rules/MissingLocalizationLintRule.swift index 7bda434..3d7253c 100644 --- a/Sources/stringray/Lint Rules/MissingLocalizationLintRule.swift +++ b/Sources/stringray/Lint Rules/MissingLocalizationLintRule.swift @@ -22,7 +22,7 @@ struct MissingLocalizationLintRule: LintRule { for entry in entries { let missingEntries = baseEntries.subtracting(entry.value) for missingEntry in missingEntries { - let file = URL(fileURLWithPath: "\(entry.key).lproj/\(table.name).strings", relativeTo: url) + let file = URL(fileURLWithPath: "\(entry.key.identifier).lproj/\(table.name).strings", relativeTo: url) let location = LintRuleViolation.Location(file: file, line: nil) let reason = "Missing \(missingEntry.key)" let violation = LintRuleViolation(locale: entry.key, location: location, severity: .warning, reason: reason) @@ -40,7 +40,7 @@ struct MissingLocalizationLintRule: LintRule { for dictEntry in dictEntries { let missingDictEntries = baseDictEntries.filter { !dictEntry.value.keys.contains($0.key) } for missingDictEntry in missingDictEntries { - let file = URL(fileURLWithPath: "\(dictEntry.key).lproj/\(table.name).stringsdict", relativeTo: url) + let file = URL(fileURLWithPath: "\(dictEntry.key.identifier).lproj/\(table.name).stringsdict", relativeTo: url) let location = LintRuleViolation.Location(file: file, line: nil) let reason = "Missing \(missingDictEntry.key)" let violation = LintRuleViolation(locale: dictEntry.key, location: location, severity: .warning, reason: reason) diff --git a/Sources/stringray/Lint Rules/MissingPlaceholderLintRule.swift b/Sources/stringray/Lint Rules/MissingPlaceholderLintRule.swift index f70fd64..9976acd 100644 --- a/Sources/stringray/Lint Rules/MissingPlaceholderLintRule.swift +++ b/Sources/stringray/Lint Rules/MissingPlaceholderLintRule.swift @@ -20,7 +20,7 @@ struct MissingPlaceholderLintRule: LintRule { try entry.value.forEach { let placeholder = try PlaceholderType.orderedPlaceholders(from: $0.value) if let basePlaceholder = placeholders[$0.key], placeholder != basePlaceholder { - let file = URL(fileURLWithPath: "\(entry.key).lproj/\(table.name).strings", relativeTo: url) + let file = URL(fileURLWithPath: "\(entry.key.identifier).lproj/\(table.name).strings", relativeTo: url) let line = $0.location?.line let location = LintRuleViolation.Location(file: file, line: line) let reason = "Mismatched placeholders \($0.key)" diff --git a/Sources/stringray/Lint Rules/OrphanedLocalizationLintRule.swift b/Sources/stringray/Lint Rules/OrphanedLocalizationLintRule.swift index 8720207..fe2b55d 100644 --- a/Sources/stringray/Lint Rules/OrphanedLocalizationLintRule.swift +++ b/Sources/stringray/Lint Rules/OrphanedLocalizationLintRule.swift @@ -18,7 +18,7 @@ struct OrphanedLocalizationLintRule: LintRule { for entry in entries { let orphanedEntries = entry.value.subtracting(baseEntries) for orphanedEntry in orphanedEntries { - let file = URL(fileURLWithPath: "\(entry.key).lproj/\(table.name).strings", relativeTo: url) + let file = URL(fileURLWithPath: "\(entry.key.identifier).lproj/\(table.name).strings", relativeTo: url) guard let line = orphanedEntry.location?.line else { continue } let location = LintRuleViolation.Location(file: file, line: line) let reason = "Orphaned \(orphanedEntry.key)" diff --git a/Sources/stringray/main.swift b/Sources/stringray/main.swift index 31e6b48..820570b 100644 --- a/Sources/stringray/main.swift +++ b/Sources/stringray/main.swift @@ -11,6 +11,7 @@ import Utility var registry = CommandRegistry(usage: " ", overview: "", version: Version(0, 1, 1)) registry.register(command: MoveCommand.self) +registry.register(command: CopyCommand.self) registry.register(command: SortCommand.self) registry.register(command: RenameCommand.self) registry.register(command: LintCommand.self)