From 276eaa8e6e48038b8ca1991491528c23fdaaac5a Mon Sep 17 00:00:00 2001 From: Geoffrey Foster Date: Wed, 12 Feb 2020 22:37:54 -0500 Subject: [PATCH] v2 --- Dockerfile | 5 - Info.plist | 16 ++ Package.resolved | 35 +-- Package.swift | 51 +++- .../RayGun/Extensions/URL+Extensions.swift | 82 ------- .../Lint Rules/MissingCommentLintRule.swift | 25 -- .../MissingLocalizationLintRule.swift | 52 ----- .../MissingPlaceholderLintRule.swift | 34 --- .../OrphanedLocalizationLintRule.swift | 31 --- .../Cache/CachedStringsTable.swift | 57 ----- .../RayGun/Strings Table/Localization.swift | 159 +++++++++++++ .../Strings Table/LocalizedString.swift | 102 ++++++++ Sources/RayGun/Strings Table/Location.swift | 36 +++ Sources/RayGun/Strings Table/Match.swift | 3 + .../Strings Table/PlaceholderType.swift | 3 +- .../RayGun/Strings Table/Pluralization.swift | 113 +++++++++ .../StringsTable+DictEntry.swift | 98 -------- .../Strings Table/StringsTable+Entry.swift | 39 ---- .../RayGun/Strings Table/StringsTable.swift | 176 -------------- .../Strings Table/StringsTableLoader.swift | 217 ------------------ Sources/RayGun/Strings Table/Table.swift | 77 +++++++ Sources/RayGun/Utilities/OrderedSet.swift | 194 ---------------- .../Lint Rules => SillyString}/LintRule.swift | 31 ++- .../Lint Rules => SillyString}/Linter.swift | 43 +++- .../Reporters/Reporter.swift | 2 +- .../Rules/DuplicateKeyLintRule.swift | 57 +++++ .../MismatchedFormatArgumentLintRule.swift | 55 +++++ .../Rules/MissingCommentLintRule.swift | 22 ++ .../Rules/MissingLocalizationLintRule.swift | 27 +++ .../NumberedFormatArgumentsLintRule.swift | 37 +++ .../Rules/OrphanedLocalizationLintRule.swift | 36 +++ .../Rules/ValidFormatArgumentsLintRule.swift | 32 +++ Sources/stringray/Commands/Copy.swift | 52 +++++ Sources/stringray/Commands/CopyCommand.swift | 61 ----- Sources/stringray/Commands/Delete.swift | 59 +++++ .../stringray/Commands/Helpers/Input.swift | 52 +++++ .../Commands/Helpers/Operation.swift | 38 +++ .../stringray/Commands/Helpers/Output.swift | 36 +++ Sources/stringray/Commands/Lint.swift | 115 ++++++++++ Sources/stringray/Commands/LintCommand.swift | 178 -------------- Sources/stringray/Commands/Move.swift | 51 ++++ Sources/stringray/Commands/MoveCommand.swift | 63 ----- Sources/stringray/Commands/Rename.swift | 22 ++ .../stringray/Commands/RenameCommand.swift | 59 ----- Sources/stringray/Commands/Sort.swift | 38 +++ Sources/stringray/Commands/SortCommand.swift | 54 ----- Sources/stringray/Commands/Stringray.swift | 24 ++ Sources/stringray/Commands/Version.swift | 21 ++ .../Files+ExpressibleByArgument.swift | 21 ++ .../stringray/Reporters/ConsoleReporter.swift | 8 +- .../stringray/Reporters/NullReporter.swift | 15 ++ .../stringray/Reporters/XcodeReporter.swift | 7 +- Sources/stringray/Version.swift | 5 - Sources/stringray/main.swift | 35 +-- Tests/LinuxMain.swift | 7 - Tests/RayGunTests/LocalizationTests.swift | 22 ++ Tests/RayGunTests/TableTests.swift | 48 ++++ .../DuplicateKeyLintRuleTests.swift | 26 +++ ...ismatchedFormatArgumentLintRuleTests.swift | 31 +++ .../MissingCommentLintRuleTests.swift | 25 ++ .../MissingLocalizationLintRuleTests.swift | 27 +++ ...NumberedFormatArgumentsLintRuleTests.swift | 28 +++ .../OrphanedLocalizationLintRuleTests.swift | 29 +++ .../ValidFormatArgumentsLintRuleTests.swift | 27 +++ Tests/stringrayTests/LinterTests.swift | 36 --- Tests/stringrayTests/XCTestManifests.swift | 9 - Tests/stringrayTests/stringrayTests.swift | 47 ---- 67 files changed, 1714 insertions(+), 1609 deletions(-) delete mode 100644 Dockerfile create mode 100644 Info.plist delete mode 100644 Sources/RayGun/Extensions/URL+Extensions.swift delete mode 100644 Sources/RayGun/Lint Rules/MissingCommentLintRule.swift delete mode 100644 Sources/RayGun/Lint Rules/MissingLocalizationLintRule.swift delete mode 100644 Sources/RayGun/Lint Rules/MissingPlaceholderLintRule.swift delete mode 100644 Sources/RayGun/Lint Rules/OrphanedLocalizationLintRule.swift delete mode 100644 Sources/RayGun/Strings Table/Cache/CachedStringsTable.swift create mode 100644 Sources/RayGun/Strings Table/Localization.swift create mode 100644 Sources/RayGun/Strings Table/LocalizedString.swift create mode 100644 Sources/RayGun/Strings Table/Location.swift create mode 100644 Sources/RayGun/Strings Table/Pluralization.swift delete mode 100644 Sources/RayGun/Strings Table/StringsTable+DictEntry.swift delete mode 100644 Sources/RayGun/Strings Table/StringsTable+Entry.swift delete mode 100644 Sources/RayGun/Strings Table/StringsTable.swift delete mode 100644 Sources/RayGun/Strings Table/StringsTableLoader.swift create mode 100644 Sources/RayGun/Strings Table/Table.swift delete mode 100644 Sources/RayGun/Utilities/OrderedSet.swift rename Sources/{RayGun/Lint Rules => SillyString}/LintRule.swift (53%) rename Sources/{RayGun/Lint Rules => SillyString}/Linter.swift (67%) rename Sources/{RayGun => SillyString}/Reporters/Reporter.swift (51%) create mode 100644 Sources/SillyString/Rules/DuplicateKeyLintRule.swift create mode 100644 Sources/SillyString/Rules/MismatchedFormatArgumentLintRule.swift create mode 100644 Sources/SillyString/Rules/MissingCommentLintRule.swift create mode 100644 Sources/SillyString/Rules/MissingLocalizationLintRule.swift create mode 100644 Sources/SillyString/Rules/NumberedFormatArgumentsLintRule.swift create mode 100644 Sources/SillyString/Rules/OrphanedLocalizationLintRule.swift create mode 100644 Sources/SillyString/Rules/ValidFormatArgumentsLintRule.swift create mode 100644 Sources/stringray/Commands/Copy.swift delete mode 100644 Sources/stringray/Commands/CopyCommand.swift create mode 100644 Sources/stringray/Commands/Delete.swift create mode 100644 Sources/stringray/Commands/Helpers/Input.swift create mode 100644 Sources/stringray/Commands/Helpers/Operation.swift create mode 100644 Sources/stringray/Commands/Helpers/Output.swift create mode 100644 Sources/stringray/Commands/Lint.swift delete mode 100644 Sources/stringray/Commands/LintCommand.swift create mode 100644 Sources/stringray/Commands/Move.swift delete mode 100644 Sources/stringray/Commands/MoveCommand.swift create mode 100644 Sources/stringray/Commands/Rename.swift delete mode 100644 Sources/stringray/Commands/RenameCommand.swift create mode 100644 Sources/stringray/Commands/Sort.swift delete mode 100644 Sources/stringray/Commands/SortCommand.swift create mode 100644 Sources/stringray/Commands/Stringray.swift create mode 100644 Sources/stringray/Commands/Version.swift create mode 100644 Sources/stringray/Files+ExpressibleByArgument.swift create mode 100644 Sources/stringray/Reporters/NullReporter.swift delete mode 100644 Sources/stringray/Version.swift create mode 100644 Tests/RayGunTests/LocalizationTests.swift create mode 100644 Tests/RayGunTests/TableTests.swift create mode 100644 Tests/SillyStringTests/DuplicateKeyLintRuleTests.swift create mode 100644 Tests/SillyStringTests/MismatchedFormatArgumentLintRuleTests.swift create mode 100644 Tests/SillyStringTests/MissingCommentLintRuleTests.swift create mode 100644 Tests/SillyStringTests/MissingLocalizationLintRuleTests.swift create mode 100644 Tests/SillyStringTests/NumberedFormatArgumentsLintRuleTests.swift create mode 100644 Tests/SillyStringTests/OrphanedLocalizationLintRuleTests.swift create mode 100644 Tests/SillyStringTests/ValidFormatArgumentsLintRuleTests.swift delete mode 100644 Tests/stringrayTests/LinterTests.swift delete mode 100644 Tests/stringrayTests/XCTestManifests.swift delete mode 100644 Tests/stringrayTests/stringrayTests.swift diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index dfba428..0000000 --- a/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -FROM swift:4.2.1 -COPY Package.swift ./Package.swift -COPY Sources ./Sources -COPY Tests ./Tests -RUN swift test --configuration debug diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..bff80e7 --- /dev/null +++ b/Info.plist @@ -0,0 +1,16 @@ + + + + + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundleShortVersionString + 0.5.0 + CFBundleVersion + 1 + NSHumanReadableCopyright + Copyright © 2020 g-Off.net. All rights reserved. + + diff --git a/Package.resolved b/Package.resolved index b1e5448..f896e02 100644 --- a/Package.resolved +++ b/Package.resolved @@ -2,21 +2,30 @@ "object": { "pins": [ { - "package": "CommandRegistry", - "repositoryURL": "https://github.com/g-Off/CommandRegistry.git", + "package": "Files", + "repositoryURL": "https://github.com/JohnSundell/Files.git", "state": { - "branch": "master", - "revision": "142aa27445e7998c5201b5ec9682698195d6701a", - "version": null + "branch": null, + "revision": "22fe84797d499ffca911ccd896b34efaf06a50b9", + "version": "4.1.1" + } + }, + { + "package": "PrintfParser", + "repositoryURL": "https://github.com/g-Off/PrintfParser.git", + "state": { + "branch": null, + "revision": "60fe5b61f6ba9cdef3955d61e7d0c4c703bcbedf", + "version": "0.1.0" } }, { - "package": "SwiftPM", - "repositoryURL": "https://github.com/apple/swift-package-manager.git", + "package": "swift-argument-parser", + "repositoryURL": "https://github.com/apple/swift-argument-parser", "state": { "branch": null, - "revision": "235aacc514cb81a6881364b0fedcb3dd083228f3", - "version": "0.3.0" + "revision": "9f04d1ff1afbccd02279338a2c91e5f27c45e93a", + "version": "0.0.5" } }, { @@ -29,12 +38,12 @@ } }, { - "package": "XcodeProject", - "repositoryURL": "https://github.com/g-Off/XcodeProject.git", + "package": "Version", + "repositoryURL": "https://github.com/mxcl/Version.git", "state": { "branch": null, - "revision": "f5095a860de4cd1f0e635957bed7c4b80392dac8", - "version": "0.5.0" + "revision": "200046c93f6d5d78a6d72bfd9c0b27a95e9c0a2b", + "version": "1.2.0" } }, { diff --git a/Package.swift b/Package.swift index ad65b3b..a4bdd70 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,10 @@ -// swift-tools-version:5.0 +// swift-tools-version:5.1 import PackageDescription let package = Package( name: "stringray", platforms: [ - .macOS(.v10_14) + .macOS("10.15") ], products: [ .executable( @@ -14,34 +14,61 @@ let package = Package( .library( name: "RayGun", targets: ["RayGun"] + ), + .library( + name: "SillyString", + targets: ["SillyString"] ) ], dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", from: "0.0.4"), .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/g-Off/XcodeProject.git", from: "0.5.0-alpha.3"), - .package(url: "https://github.com/g-Off/CommandRegistry.git", from: "0.1.0"), - .package(url: "https://github.com/apple/swift-package-manager.git", from: "0.3.0") + .package(url: "https://github.com/JohnSundell/Files.git", from: "4.0.0"), + .package(url: "https://github.com/mxcl/Version.git", from: "1.0.0"), + .package(url: "https://github.com/g-Off/PrintfParser.git", from: "0.1.0") ], targets: [ .target( name: "stringray", dependencies: [ - "CommandRegistry", - "RayGun", + "ArgumentParser", + "SillyString", "SwiftyTextTable", - "XcodeProject", - "Utility", "Yams", + "Version" + ], + linkerSettings: [ + .unsafeFlags(["-Xlinker", "-sectcreate"], .when(platforms: [.macOS])), + .unsafeFlags(["-Xlinker", "__TEXT"], .when(platforms: [.macOS])), + .unsafeFlags(["-Xlinker", "__info_plist"], .when(platforms: [.macOS])), + .unsafeFlags(["-Xlinker", "Info.plist"], .when(platforms: [.macOS])) ] ), .target( name: "RayGun", dependencies: [ + "Files" + ] + ), + .target( + name: "SillyString", + dependencies: [ + "RayGun", + "PrintfParser" ] ), .testTarget( - name: "stringrayTests", - dependencies: ["RayGun"]), - ] + name: "RayGunTests", + dependencies: ["RayGun"] + ), + .testTarget( + name: "SillyStringTests", + dependencies: ["SillyString"] + ), +// .testTarget( +// name: "stringrayTests", +// dependencies: ["RayGun", "SillyString"] +// ), + ] ) diff --git a/Sources/RayGun/Extensions/URL+Extensions.swift b/Sources/RayGun/Extensions/URL+Extensions.swift deleted file mode 100644 index 856e271..0000000 --- a/Sources/RayGun/Extensions/URL+Extensions.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// URL+Extensions.swift -// stringray -// -// Created by Geoffrey Foster on 2018-11-10. -// - -import Foundation - -extension Foundation.URL { - public var tableName: String? { - var url = self - if ["strings", "stringsdict"].contains(url.pathExtension) { - url.deletePathExtension() - return url.lastPathComponent - } - return nil - } - - public var locale: Locale? { - var url = self - if ["strings", "stringsdict"].contains(url.pathExtension) { - url.deleteLastPathComponent() - } - if url.pathExtension == "lproj" { - url.deletePathExtension() - return Locale(identifier: url.lastPathComponent) - } - return nil - } - - public var resourceDirectory: Foundation.URL { - var dir = self - if dir.pathExtension == "strings" || dir.pathExtension == "stringsdict" { - dir.deleteLastPathComponent() - } - if dir.pathExtension == "lproj" { - dir.deleteLastPathComponent() - } - return dir - } - - var lprojURLs: [Foundation.URL] { - let directories = try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil, options: []).filter { (url) -> Bool in - return url.pathExtension == "lproj" - } - return directories ?? [] - } - - func stringsFiles(tableName: String) -> [Foundation.URL] { - return files(tableName: tableName, ext: "strings") - } - - func stringsDictFiles(tableName: String) -> [Foundation.URL] { - return files(tableName: tableName, ext: "stringsdict") - } - - private func files(tableName: String, ext: String) -> [Foundation.URL] { - return lprojURLs.compactMap { (lprojURL) in - let url = lprojURL.appendingPathComponent(tableName).appendingPathExtension(ext) - guard let reachable = try? url.checkResourceIsReachable(), reachable == true else { return nil } - return url - } - } - - func stringsURL(tableName: String, locale: Locale) throws -> Foundation.URL { - return try fileURL(tableName: tableName, locale: locale, ext: "strings", create: true) - } - - func stringsDictURL(tableName: String, locale: Locale) throws -> Foundation.URL { - return try fileURL(tableName: tableName, locale: locale, ext: "stringsdict", create: true) - } - - private func fileURL(tableName: String, locale: Locale, ext: String, create: Bool) throws -> Foundation.URL { - let lprojURL = appendingPathComponent("\(locale.identifier).lproj", isDirectory: true) - if create { - try FileManager.default.createDirectory(at: lprojURL, withIntermediateDirectories: true, attributes: nil) - } - let fileURL = lprojURL.appendingPathComponent(tableName).appendingPathExtension(ext) - return fileURL - } -} diff --git a/Sources/RayGun/Lint Rules/MissingCommentLintRule.swift b/Sources/RayGun/Lint Rules/MissingCommentLintRule.swift deleted file mode 100644 index d4f4402..0000000 --- a/Sources/RayGun/Lint Rules/MissingCommentLintRule.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// MissingCommentLintRule.swift -// RayGun -// -// Created by Geoffrey Foster on 2019-06-02. -// - -import Foundation - -struct MissingCommentLintRule: LintRule { - let info: RuleInfo = RuleInfo(identifier: "missing_comment", name: "Missing Comment", description: "", severity: .error) - - func scan(table: StringsTable, url: Foundation.URL, config: Linter.Config.Rule?) throws -> [LintRuleViolation] { - var violations: [LintRuleViolation] = [] - let file = Foundation.URL(fileURLWithPath: "\(table.base.identifier).lproj/\(table.name).strings", relativeTo: url) - for entry in table.baseEntries where entry.comment == nil { - let line = entry.location?.line - let location = LintRuleViolation.Location(file: file, line: line) - let reason = "Mismatched placeholders \(entry.key)" - let violation = LintRuleViolation(locale: table.base, location: location, severity: config?.severity ?? info.severity, reason: reason) - violations.append(violation) - } - return violations - } -} diff --git a/Sources/RayGun/Lint Rules/MissingLocalizationLintRule.swift b/Sources/RayGun/Lint Rules/MissingLocalizationLintRule.swift deleted file mode 100644 index 066625a..0000000 --- a/Sources/RayGun/Lint Rules/MissingLocalizationLintRule.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// MissingLocalizationLintRule.swift -// stringray -// -// Created by Geoffrey Foster on 2018-11-07. -// - -import Foundation - -struct MissingLocalizationLintRule: LintRule { - let info: RuleInfo = RuleInfo(identifier: "missing_localization", name: "Missing Localization", description: "", severity: .warning) - - func scan(table: StringsTable, url: Foundation.URL, config: Linter.Config.Rule?) throws -> [LintRuleViolation] { - return scanEntries(table: table, url: url, config: config) + scanDictEntries(table: table, url: url, config: config) - } - - private func scanEntries(table: StringsTable, url: Foundation.URL, config: Linter.Config.Rule?) -> [LintRuleViolation] { - var violations: [LintRuleViolation] = [] - var entries = table.entries - entries.removeValue(forKey: table.base) - let baseEntries = table.baseEntries - for entry in entries { - let missingEntries = baseEntries.subtracting(entry.value) - for missingEntry in missingEntries { - let file = Foundation.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: config?.severity ?? info.severity, reason: reason) - violations.append(violation) - } - } - return violations - } - - private func scanDictEntries(table: StringsTable, url: Foundation.URL, config: Linter.Config.Rule?) -> [LintRuleViolation] { - var violations: [LintRuleViolation] = [] - var dictEntries = table.dictEntries - dictEntries.removeValue(forKey: table.base) - let baseDictEntries = table.baseDictEntries - for dictEntry in dictEntries { - let missingDictEntries = baseDictEntries.filter { !dictEntry.value.keys.contains($0.key) } - for missingDictEntry in missingDictEntries { - let file = Foundation.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: config?.severity ?? info.severity, reason: reason) - violations.append(violation) - } - } - return violations - } -} diff --git a/Sources/RayGun/Lint Rules/MissingPlaceholderLintRule.swift b/Sources/RayGun/Lint Rules/MissingPlaceholderLintRule.swift deleted file mode 100644 index b03de00..0000000 --- a/Sources/RayGun/Lint Rules/MissingPlaceholderLintRule.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// MissingPlaceholderLintRule.swift -// stringray -// -// Created by Geoffrey Foster on 2018-11-20. -// - -import Foundation - -struct MissingPlaceholderLintRule: LintRule { - let info: RuleInfo = RuleInfo(identifier: "missing_placeholder", name: "Missing Placeholder", description: "", severity: .error) - - func scan(table: StringsTable, url: Foundation.URL, config: Linter.Config.Rule?) throws -> [LintRuleViolation] { - var violations: [LintRuleViolation] = [] - var placeholders: [String: [PlaceholderType]] = [:] - try table.baseEntries.forEach { - placeholders[$0.key] = try PlaceholderType.orderedPlaceholders(from: $0.value) - } - for entry in table.localizedEntries { - try entry.value.forEach { - let placeholder = try PlaceholderType.orderedPlaceholders(from: $0.value) - if let basePlaceholder = placeholders[$0.key], placeholder != basePlaceholder { - let file = Foundation.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)" - let violation = LintRuleViolation(locale: entry.key, location: location, severity: config?.severity ?? info.severity, reason: reason) - violations.append(violation) - } - } - } - return violations - } -} diff --git a/Sources/RayGun/Lint Rules/OrphanedLocalizationLintRule.swift b/Sources/RayGun/Lint Rules/OrphanedLocalizationLintRule.swift deleted file mode 100644 index 5137215..0000000 --- a/Sources/RayGun/Lint Rules/OrphanedLocalizationLintRule.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// OrphanedLocalizationLintRule.swift -// stringray -// -// Created by Geoffrey Foster on 2018-11-07. -// - -import Foundation - -struct OrphanedLocalizationLintRule: LintRule { - let info: RuleInfo = RuleInfo(identifier: "orphaned_localization", name: "Orphaned Localization", description: "", severity: .warning) - - func scan(table: StringsTable, url: Foundation.URL, config: Linter.Config.Rule?) throws -> [LintRuleViolation] { - var violations: [LintRuleViolation] = [] - var entries = table.entries - entries.removeValue(forKey: table.base) - let baseEntries = table.baseEntries - for entry in entries { - let orphanedEntries = entry.value.subtracting(baseEntries) - for orphanedEntry in orphanedEntries { - let file = Foundation.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)" - let violation = LintRuleViolation(locale: entry.key, location: location, severity: config?.severity ?? info.severity, reason: reason) - violations.append(violation) - } - } - return violations - } -} diff --git a/Sources/RayGun/Strings Table/Cache/CachedStringsTable.swift b/Sources/RayGun/Strings Table/Cache/CachedStringsTable.swift deleted file mode 100644 index 0e00398..0000000 --- a/Sources/RayGun/Strings Table/Cache/CachedStringsTable.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// CachedStringsTable.swift -// stringray -// -// Created by Geoffrey Foster on 2018-12-12. -// - -import Foundation - -struct CachedStringsTable: Codable { - let stringsTable: StringsTable - let cacheKeys: [String: Date] - - enum LocalizationType { - case strings - case stringsdict - } - - init(stringsTable: StringsTable, cacheKeys: [String: Date]) { - self.stringsTable = stringsTable - self.cacheKeys = cacheKeys - } - - func strings(for locale: Locale) -> OrderedSet? { - return stringsTable.entries[locale] - } - - func stringsDict(for locale: Locale) -> [String: StringsTable.DictEntry]? { - return stringsTable.dictEntries[locale] - } - - func isCacheValid(for locale: Locale, type: LocalizationType, base: Foundation.URL) -> Bool { - do { - let fileURL: Foundation.URL - switch type { - case .strings: - fileURL = try base.stringsURL(tableName: stringsTable.name, locale: locale) - case .stringsdict: - fileURL = try base.stringsDictURL(tableName: stringsTable.name, locale: locale) - } - let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - guard let modificationDate = attributes[.modificationDate] as? Date else { return false } - return modificationDate == cacheKeys[CachedStringsTable.cacheKey(for: locale, type: type)] - } catch { - return false - } - } - - static func cacheKey(for locale: Locale, type: LocalizationType) -> String { - switch type { - case .strings: - return "\(locale.identifier).strings" - case .stringsdict: - return "\(locale.identifier).stringsdict" - } - } -} diff --git a/Sources/RayGun/Strings Table/Localization.swift b/Sources/RayGun/Strings Table/Localization.swift new file mode 100644 index 0000000..b895a56 --- /dev/null +++ b/Sources/RayGun/Strings Table/Localization.swift @@ -0,0 +1,159 @@ +// +// Localization.swift +// +// +// Created by Geoffrey Foster on 2020-02-09. +// + +import Foundation +import Files + +public final class Localization { + public private(set) var all: [LocalizedString] + + public var strings: [LocalizedString] { all.filter { !$0.isPlural } } + public var pluralizations: [LocalizedString] { all.filter { $0.isPlural } } + + public var allKeys: Set { Set(all.map { $0.key }) } + + /// The table name of this localization + public let name: String + /// The locale of this localization + public let locale: String + + /// Initializes a new table with the given name for the given locale. + /// - Parameters: + /// - name: The name of the table. + /// - locale: The locale. + /// - strings: + public init(name: String, locale: String, strings: [LocalizedString] = []) { + self.name = name + self.locale = locale + self.all = strings + } + + public convenience init(name: String, folder: Folder) throws { + var strings: [LocalizedString] = [] + let locale = folder.nameExcludingExtension + if let file = try? folder.file(named: "\(name).strings") { + let contents = try LocalizedString.parse(string: try file.readAsString()) + strings.append(contentsOf: contents) + } + if let file = try? folder.file(named: "\(name).stringsdict") { + let contents = try LocalizedString.load(data: try file.read()) + strings.append(contentsOf: contents) + } + self.init(name: name, locale: locale, strings: strings) + } + + /// Returns all strings with a matching key. + public subscript(key: String) -> [LocalizedString] { + all.filter { $0.key == key } + } + + /// Returns all strings with a key that matches the given `Match`. + public subscript(match: Match) -> [LocalizedString] { + all.filter { match.matches(key: $0.key) } + } + + public func add(_ string: LocalizedString) { + all.append(string) + } + + public func add(key: String, value: String, comment: String? = nil) { + add(LocalizedString(key: key, value: .text(value), comment: comment, location: nil)) + } + + public func add(_ strings: [LocalizedString]) { + self.all.append(contentsOf: strings) + } + + public func remove(key: String) { + all.removeAll { + $0.key == key + } + } + + public func removeAll() { + all.removeAll() + } + + public func removeAll(where shouldBeRemoved: (LocalizedString) throws -> Bool) rethrows { + try all.removeAll(where: shouldBeRemoved) + } + + public func remove(_ localizedStrings: Set) { + all.removeAll { localizedStrings.contains($0) } + } + + public func replace(matches: [Match], replacements replacementStrings: [String]) { + for (match, replacement) in zip(matches, replacementStrings) { + for i in 0.. Bool in + lhs.key < rhs.key + } + } +} + +private extension FileHandle { + private static let newline: Data = "\n".data(using: .utf8)! + func write(_ string: String) { + guard let data = string.data(using: .utf8) else { return } + write(data) + write(Self.newline) + } +} diff --git a/Sources/RayGun/Strings Table/LocalizedString.swift b/Sources/RayGun/Strings Table/LocalizedString.swift new file mode 100644 index 0000000..e952d91 --- /dev/null +++ b/Sources/RayGun/Strings Table/LocalizedString.swift @@ -0,0 +1,102 @@ +// +// LocalizedString.swift +// +// +// Created by Geoffrey Foster on 2020-02-09. +// + +import Foundation + +public struct LocalizedString { + public enum Value: Equatable, Hashable { + case text(String) + case plural(Pluralization) + } + + public var key: String + public var value: Value + + public var comment: String? + public var location: Location? +} + +extension LocalizedString: Equatable { + public static func == (lhs: LocalizedString, rhs: LocalizedString) -> Bool { + return lhs.key == rhs.key && lhs.value == rhs.value + } +} + +extension LocalizedString: Hashable {} + +public extension LocalizedString { + var text: String { + guard case let .text(string) = value else { return "" } + return string + } + + var pluralization: Pluralization? { + guard case let .plural(pluralization) = value else { return nil } + return pluralization + } +} + +extension LocalizedString { + var isPlural: Bool { + if case .plural = value { + return true + } + return false + } +} + +extension LocalizedString { + static func parse(string: String) throws -> [LocalizedString] { + var strings: [LocalizedString] = [] + let regex = try NSRegularExpression(pattern: "\"(?.*)\"\\s*=\\s*\"(?.*)\"", options: []) + + let scanner = Scanner(string: string) + while !scanner.isAtEnd { + var comment: String? + var key: String? + var value: String? + + if let _ = scanner.scanString("/*") { + comment = scanner.scanUpToString("*/\n")?.trimmingCharacters(in: CharacterSet.whitespaces) + _ = scanner.scanString("*/\n") + } + _ = scanner.scanCharacters(from: .whitespacesAndNewlines) + if let scannedString = scanner.scanUpToString(";\n") { + let range = NSRange(scannedString.startIndex..(result.range(withName: "key"), in: scannedString), + let valueRange = Range(result.range(withName: "value"), in: scannedString) else { + return + } + key = String(scannedString[keyRange]) + value = String(scannedString[valueRange]) + } + _ = scanner.scanString(";\n") + } + + if let key = key, let value = value { + // TODO: capture location + strings.append(LocalizedString(key: key, value: .text(value), comment: comment, location: nil)) + } + + _ = scanner.scanCharacters(from: .whitespacesAndNewlines) + } + return strings + } + + static func load(data: Data) throws -> [LocalizedString] { + var strings: [LocalizedString] = [] + let decoder = PropertyListDecoder() + let pluralizations = try decoder.decode([String: Pluralization].self, from: data) + pluralizations.forEach { (key, pluralization) in + strings.append(LocalizedString(key: key, value: .plural(pluralization), comment: nil, location: nil)) + } + return strings + } +} diff --git a/Sources/RayGun/Strings Table/Location.swift b/Sources/RayGun/Strings Table/Location.swift new file mode 100644 index 0000000..47f1c90 --- /dev/null +++ b/Sources/RayGun/Strings Table/Location.swift @@ -0,0 +1,36 @@ +// +// File.swift +// +// +// Created by Geoffrey Foster on 2020-02-09. +// + +import Foundation + +public struct Location { + public let file: String + public let line: UInt? + public let character: UInt? + + public init(file: String, line: UInt? = nil, character: UInt? = nil) { + self.file = file + self.line = line + self.character = character + } +} + +extension Location: Equatable {} +extension Location: Hashable {} + +extension Location: CustomStringConvertible { + public var description: String { + var path = file + if let line = line { + path.append(":\(line)") + } + if let character = character { + path.append(":\(character)") + } + return path + } +} diff --git a/Sources/RayGun/Strings Table/Match.swift b/Sources/RayGun/Strings Table/Match.swift index 1b702ea..489ec65 100644 --- a/Sources/RayGun/Strings Table/Match.swift +++ b/Sources/RayGun/Strings Table/Match.swift @@ -9,12 +9,15 @@ import Foundation public enum Match { case prefix(String) + case exact(String) case regex(NSRegularExpression) public func matches(key: String) -> Bool { switch self { case .prefix(let prefix): return key.hasPrefix(prefix) + case .exact(let exact): + return key == exact case .regex(_): // TODO: support this eventually return false } diff --git a/Sources/RayGun/Strings Table/PlaceholderType.swift b/Sources/RayGun/Strings Table/PlaceholderType.swift index eed0b7c..36b461d 100644 --- a/Sources/RayGun/Strings Table/PlaceholderType.swift +++ b/Sources/RayGun/Strings Table/PlaceholderType.swift @@ -4,6 +4,7 @@ // // Created by Geoffrey Foster on 2018-11-13. // +// Based on the work in https://github.com/SwiftGen/SwiftGen/blob/master/Sources/SwiftGenKit/Parsers/Strings/PlaceholderType.swift import Foundation @@ -74,7 +75,7 @@ public enum PlaceholderType: String, Codable { return placeholders } - static func orderedPlaceholders(from formatString: String) throws -> [PlaceholderType] { + public static func orderedPlaceholders(from formatString: String) throws -> [PlaceholderType] { let unsorted = try placeholders(from: formatString) var sorted = Array(repeating: PlaceholderType.unknown, count: unsorted.count) for (index, element) in unsorted.enumerated() { diff --git a/Sources/RayGun/Strings Table/Pluralization.swift b/Sources/RayGun/Strings Table/Pluralization.swift new file mode 100644 index 0000000..33b5b00 --- /dev/null +++ b/Sources/RayGun/Strings Table/Pluralization.swift @@ -0,0 +1,113 @@ +// +// Pluralization.swift +// +// +// Created by Geoffrey Foster on 2020-02-09. +// + +import Foundation + +public struct Pluralization: Codable, Hashable { + private struct _DictKey: CodingKey, Equatable { + var stringValue: String + var intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } + + static let localizedFormatKey = _DictKey(stringValue: "NSStringLocalizedFormatKey")! + static func pluralization(_ key: String) -> _DictKey { + return _DictKey(stringValue: key)! + } + } + + struct PluralizationRule: Codable, Hashable { + private enum CodingKeys: String, CodingKey { + case zero, one, two, few, many, other + case specType = "NSStringFormatSpecTypeKey" + case valueType = "NSStringFormatValueTypeKey" + } + private static let pluralRuleType = "NSStringPluralRuleType" + // CodingKeys should have a key/value pair of NSStringFormatSpecTypeKey/NSStringPluralRuleType + let zero: String? + let one: String? + let two: String? + let few: String? + let many: String? + let other: String + + let valueType: String + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + //precondition(try container.decode(String.self, forKey: .formatSpecType) == Self.pluralRuleType) + self.zero = try container.decodeIfPresent(String.self, forKey: .zero) + self.one = try container.decodeIfPresent(String.self, forKey: .one) + self.two = try container.decodeIfPresent(String.self, forKey: .two) + self.few = try container.decodeIfPresent(String.self, forKey: .few) + self.many = try container.decodeIfPresent(String.self, forKey: .many) + self.other = try container.decode(String.self, forKey: .other) + self.valueType = try container.decode(String.self, forKey: .valueType) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(zero, forKey: .zero) + try container.encodeIfPresent(one, forKey: .one) + try container.encodeIfPresent(two, forKey: .two) + try container.encodeIfPresent(few, forKey: .few) + try container.encodeIfPresent(many, forKey: .many) + try container.encode(other, forKey: .other) + try container.encode(Self.pluralRuleType, forKey: .specType) + try container.encode(valueType, forKey: .valueType) + } + } + + public struct Variable: Hashable, Codable { + public let name: String + + public init(_ name: String) { + self.name = name + } + + public init(from decoder: Decoder) throws { + self.name = try decoder.singleValueContainer().decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(name) + } + } + + let formatKey: String + let pluralizations: [Variable: PluralizationRule] + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: _DictKey.self) + self.formatKey = try container.decode(String.self, forKey: _DictKey.localizedFormatKey) + let allKeys = container.allKeys.filter { + return $0 != _DictKey.localizedFormatKey + } + let elements: [(Variable, PluralizationRule)] = try allKeys.map { (key) in + let pluralization = try container.decode(PluralizationRule.self, forKey: key) + return (Variable(key.stringValue), pluralization) + } + self.pluralizations = Dictionary(elements, uniquingKeysWith: { (lhs, _) in return lhs }) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: _DictKey.self) + try container.encode(formatKey, forKey: _DictKey.localizedFormatKey) + try pluralizations.forEach { + try container.encode($0.value, forKey: _DictKey(stringValue: $0.key.name)!) + } + } +} diff --git a/Sources/RayGun/Strings Table/StringsTable+DictEntry.swift b/Sources/RayGun/Strings Table/StringsTable+DictEntry.swift deleted file mode 100644 index c7b6092..0000000 --- a/Sources/RayGun/Strings Table/StringsTable+DictEntry.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// StringsTable+DictEntry.swift -// stringray -// -// Created by Geoffrey Foster on 2018-11-09. -// - -import Foundation - -extension StringsTable { - public struct DictEntry: Codable, Hashable { - private struct _DictKey: CodingKey, Equatable { - var stringValue: String - var intValue: Int? - - init?(stringValue: String) { - self.stringValue = stringValue - self.intValue = nil - } - - init?(intValue: Int) { - self.stringValue = "\(intValue)" - self.intValue = intValue - } - - static let localizedFormatKey = _DictKey(stringValue: "NSStringLocalizedFormatKey")! - static func pluralization(_ key: String) -> _DictKey { - return _DictKey(stringValue: key)! - } - } - - struct PluralizationRule: Codable, Hashable { - private enum CodingKeys: String, CodingKey { - case zero, one, two, few, many, other - case specType = "NSStringFormatSpecTypeKey" - case valueType = "NSStringFormatValueTypeKey" - } - private static let pluralRuleType = "NSStringPluralRuleType" - // CodingKeys should have a key/value pair of NSStringFormatSpecTypeKey/NSStringPluralRuleType - let zero: String? - let one: String? - let two: String? - let few: String? - let many: String? - let other: String - - let valueType: String - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - //precondition(try container.decode(String.self, forKey: .formatSpecType) == type(of: self).pluralRuleType) - self.zero = try container.decodeIfPresent(String.self, forKey: .zero) - self.one = try container.decodeIfPresent(String.self, forKey: .one) - self.two = try container.decodeIfPresent(String.self, forKey: .two) - self.few = try container.decodeIfPresent(String.self, forKey: .few) - self.many = try container.decodeIfPresent(String.self, forKey: .many) - self.other = try container.decode(String.self, forKey: .other) - self.valueType = try container.decode(String.self, forKey: .valueType) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encodeIfPresent(zero, forKey: .zero) - try container.encodeIfPresent(one, forKey: .one) - try container.encodeIfPresent(two, forKey: .two) - try container.encodeIfPresent(few, forKey: .few) - try container.encodeIfPresent(many, forKey: .many) - try container.encode(other, forKey: .other) - try container.encode(type(of: self).pluralRuleType, forKey: .specType) - try container.encode(valueType, forKey: .valueType) - } - } - - let formatKey: String - let pluralizations: [String: PluralizationRule] - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: _DictKey.self) - self.formatKey = try container.decode(String.self, forKey: _DictKey.localizedFormatKey) - let allKeys = container.allKeys.filter { - return $0 != _DictKey.localizedFormatKey - } - let elements: [(String, PluralizationRule)] = try allKeys.map { (key) in - let pluralization = try container.decode(PluralizationRule.self, forKey: key) - return (key.stringValue, pluralization) - } - self.pluralizations = Dictionary(elements, uniquingKeysWith: { (lhs, _) in return lhs }) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: _DictKey.self) - try container.encode(formatKey, forKey: _DictKey.localizedFormatKey) - try pluralizations.forEach { - try container.encode($0.value, forKey: _DictKey(stringValue: $0.key)!) - } - } - } -} diff --git a/Sources/RayGun/Strings Table/StringsTable+Entry.swift b/Sources/RayGun/Strings Table/StringsTable+Entry.swift deleted file mode 100644 index 221824a..0000000 --- a/Sources/RayGun/Strings Table/StringsTable+Entry.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// StringsTable+Entry.swift -// stringray -// -// Created by Geoffrey Foster on 2018-11-09. -// - -import Foundation - -extension StringsTable { - public struct Entry: Codable, Hashable, CustomStringConvertible { - public struct Location: Codable { - var comment: Int? = nil - var line: Int = NSNotFound - } - - var location: Location? = nil - var comment: String? = nil - var key: String = "" - var value: String = "" - - public var description: String { - var string = "" - if let comment = comment { - string.append("/* \(comment) */\n") - } - string.append("\"\(key)\" = \"\(value)\";") - return string - } - - public static func ==(lhs: Entry, rhs: Entry) -> Bool { - return lhs.key == rhs.key - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(key) - } - } -} diff --git a/Sources/RayGun/Strings Table/StringsTable.swift b/Sources/RayGun/Strings Table/StringsTable.swift deleted file mode 100644 index dabaabc..0000000 --- a/Sources/RayGun/Strings Table/StringsTable.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// StringsTable.swift -// stringray -// -// Created by Geoffrey Foster on 2018-11-02. -// Copyright © 2018 g-Off.net. All rights reserved. -// - -import Foundation - -public struct StringsTable: Codable { - public typealias EntriesType = [Locale: OrderedSet] - public typealias DictEntriesType = [Locale: [String: DictEntry]] - - private enum CodingKeys: String, CodingKey { - case name - case base - case entries - case dictEntries - } - - public let name: String - public let base: Locale - public private(set) var entries: EntriesType = [:] - public private(set) var dictEntries: DictEntriesType = [:] - - private var allLanguageKeys: Set { - var keys: Set = [] - keys.formUnion(entries.keys) - keys.formUnion(dictEntries.keys) - return keys - } - - public var baseEntries: OrderedSet { - return entries[base] ?? [] - } - - public var localizedEntries: EntriesType { - var localizedEntries = entries - localizedEntries.removeValue(forKey: base) - return localizedEntries - } - - public var baseDictEntries: [String: DictEntry] { - return dictEntries[base] ?? [:] - } - - public init(name: String, base: Locale, entries: EntriesType = [:], dictEntries: DictEntriesType = [:]) { - self.name = name - self.base = base - self.entries = entries - self.dictEntries = dictEntries - } - - private func entries(for locale: Locale, matching: [Match]) -> OrderedSet? { - guard let matchingEntries = entries[locale]?.filter({ (entry) -> Bool in - return matching.matches(key: entry.key) - }) else { return nil } - return OrderedSet(matchingEntries) - } - - public func withKeys(matching: [Match]) -> StringsTable { - var filteredEntries: EntriesType = [:] - var filteredDictEntries: DictEntriesType = [:] - - for locale in allLanguageKeys { - if let matchingEntries = entries(for: locale, matching: matching) { - filteredEntries[locale] = matchingEntries - } - - if let matchingDictEntries = dictEntries[locale]?.filter({ (key, value) -> Bool in - return matching.matches(key: key) - }) { - filteredDictEntries[locale] = matchingDictEntries - } - } - - var table = self - table.entries = filteredEntries - table.dictEntries = filteredDictEntries - return table - } - - public mutating func addEntries(from table: StringsTable) { - for (languageId, languageEntries) in table.entries { - entries[languageId, default: []].formUnion(languageEntries) - } - - for (languageId, languageEntries) in table.dictEntries { - dictEntries[languageId, default: [:]].merge(languageEntries, uniquingKeysWith: { (lhs, rhs) in - return rhs - }) - } - } - - public mutating func removeEntries(from table: StringsTable) { - for (languageId, languageEntries) in table.entries { - entries[languageId]?.subtract(languageEntries) - } - - for (languageId, languageEntries) in table.dictEntries { - languageEntries.keys.forEach { - dictEntries[languageId]?.removeValue(forKey: $0) - } - } - } - - public mutating func sort() { - for (languageId, languageEntries) in entries { - var sortedLanguageEntries = languageEntries - sortedLanguageEntries.sort { (lhs, rhs) -> Bool in - return lhs.key < rhs.key - } - entries.updateValue(sortedLanguageEntries, forKey: languageId) - } - } - - public mutating func remove(keys: Set) { - for (locale, entry) in entries { - let filtered = entry.filter { - return !keys.contains($0.key) - } - entries[locale] = OrderedSet(filtered) - } - } - - private mutating func replace(entry: Entry, with otherEntry: Entry, locale: Locale) { - guard let index = entries[locale]?.firstIndex(of: entry) else { return } - entries[locale]?[index] = otherEntry - } - - private mutating func replace(key: String, with otherKey: String, locale: Locale) { - guard let entry = dictEntries[locale]?[key] else { return } - dictEntries[locale]?[otherKey] = entry - } - - public mutating func replace(matches: [Match], replacements replacementStrings: [String]) { - for (match, replacement) in zip(matches, replacementStrings) { - for localizedEntries in entriesMatching(match) { - localizedEntries.value.forEach { - var entry = $0 - if let replacementKey = match.replacing(with: replacement, in: entry.key) { - entry.key = replacementKey - } - replace(entry: $0, with: entry, locale: localizedEntries.key) - } - } - - for localizedEntries in dictEntriesMatching(match) { - localizedEntries.value.forEach { - if let replacementKey = match.replacing(with: replacement, in: $0.key) { - replace(key: $0.key, with: replacementKey, locale: localizedEntries.key) - } - } - } - } - } - - // MARK: - - - private func entriesMatching(_ match: Match) -> EntriesType { - var matches: EntriesType = [:] - for localizedEntry in entries { - matches[localizedEntry.key] = OrderedSet(localizedEntry.value.filter({ match.matches(key: $0.key) })) - } - return matches - } - - private func dictEntriesMatching(_ match: Match) -> DictEntriesType { - var matches: DictEntriesType = [:] - for localizedEntry in dictEntries { - matches[localizedEntry.key] = localizedEntry.value.filter { match.matches(key: $0.key) } - } - return matches - } -} diff --git a/Sources/RayGun/Strings Table/StringsTableLoader.swift b/Sources/RayGun/Strings Table/StringsTableLoader.swift deleted file mode 100644 index 040d562..0000000 --- a/Sources/RayGun/Strings Table/StringsTableLoader.swift +++ /dev/null @@ -1,217 +0,0 @@ -// -// StringsTableLoader.swift -// stringray -// -// Created by Geoffrey Foster on 2018-11-20. -// - -import Foundation - -public struct StringsTableLoader { - private enum Error: String, Swift.Error, LocalizedError { - case invalidURL - - var errorDescription: String? { - switch self { - case .invalidURL: - return "Invalid string resource URL provided." - } - } - } - - public struct Options: OptionSet { - public private(set) var rawValue: UInt - public init(rawValue: UInt) { self.rawValue = rawValue } - - public static let lineNumbers = Options(rawValue: 1 << 0) - public static let ignoreCached = Options(rawValue: 1 << 1) - public static let singleLocale = Options(rawValue: 1 << 2) - } - - public let options: Options - - public init(options: Options = []) { - self.options = options - } - - public func load(url: Foundation.URL) throws -> StringsTable { - let resourceDirectory = url.resourceDirectory - guard let name = url.tableName, let base = url.locale else { - throw Error.invalidURL - } - return try self.load(url: resourceDirectory, name: name, base: base) - } - - public func load(url: Foundation.URL, name: String, base: Locale) throws -> StringsTable { - var entries: StringsTable.EntriesType = [:] - var dictEntries: StringsTable.DictEntriesType = [:] - - var cached: CachedStringsTable? - if !options.contains(.ignoreCached), let url = cacheURL(for: name), let data = try? Data(contentsOf: url) { - let decoder = PropertyListDecoder() - cached = try? decoder.decode(CachedStringsTable.self, from: data) - } - - let lprojURLs: [URL] - if options.contains(.singleLocale) { - lprojURLs = [URL(fileURLWithPath: "\(base.identifier).lproj", isDirectory: false, relativeTo: url)] - } else { - lprojURLs = url.lprojURLs - } - - try lprojURLs.forEach { - guard let locale = $0.locale else { return } - - let stringsTableURL = $0.appendingPathComponent(name).appendingPathExtension("strings") - if let cached = cached, cached.isCacheValid(for: locale, type: .strings, base: url), let cachedStrings = cached.strings(for: locale) { - entries[locale] = cachedStrings - } else if let reachable = try? stringsTableURL.checkResourceIsReachable(), reachable == true { - entries[locale] = try load(from: stringsTableURL, options: options) - } - - let stringsDictTableURL = $0.appendingPathComponent(name).appendingPathExtension("stringsdict") - if let cached = cached, cached.isCacheValid(for: locale, type: .stringsdict, base: url), let cachedStringsDict = cached.stringsDict(for: locale) { - dictEntries[locale] = cachedStringsDict - } else if let reachable = try? stringsDictTableURL.checkResourceIsReachable(), reachable == true { - dictEntries[locale] = try load(from: stringsDictTableURL) - } - } - - return StringsTable(name: name, base: base, entries: entries, dictEntries: dictEntries) - } - - public func write(to url: Foundation.URL, table: StringsTable) throws { - for (languageId, languageEntries) in table.entries where !languageEntries.isEmpty { - let fileURL = try url.stringsURL(tableName: table.name, locale: languageId) - guard let outputStream = OutputStream(url: fileURL, append: false) else { continue } - outputStream.open() - var firstEntry = true - for entry in languageEntries { - if !firstEntry { - outputStream.write(string: "\n") - } - firstEntry = false - outputStream.write(string: "\(entry)\n") - } - outputStream.close() - } - - for (languageId, languageEntries) in table.dictEntries where !languageEntries.isEmpty { - let fileURL = try url.stringsDictURL(tableName: table.name, locale: languageId) - let encoder = PropertyListEncoder() - encoder.outputFormat = .xml - let data = try encoder.encode(languageEntries) - try data.write(to: fileURL, options: [.atomic]) - } - } - - public func writeCache(table: StringsTable, baseURL: Foundation.URL) throws { - var cacheKeys: [String: Date] = [:] - - for (languageId, languageEntries) in table.entries where !languageEntries.isEmpty { - let fileURL = try baseURL.stringsURL(tableName: table.name, locale: languageId) - let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - guard let modificationDate = attributes[.modificationDate] as? Date else { continue } - cacheKeys[CachedStringsTable.cacheKey(for: languageId, type: .strings)] = modificationDate - } - - for (languageId, languageEntries) in table.dictEntries where !languageEntries.isEmpty { - let fileURL = try baseURL.stringsDictURL(tableName: table.name, locale: languageId) - let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - guard let modificationDate = attributes[.modificationDate] as? Date else { continue } - cacheKeys[CachedStringsTable.cacheKey(for: languageId, type: .stringsdict)] = modificationDate - } - - let cachedTable = CachedStringsTable(stringsTable: table, cacheKeys: cacheKeys) - let encoder = PropertyListEncoder() - encoder.outputFormat = .binary - let cachedData = try encoder.encode(cachedTable) - guard let url = cacheURL(for: table.name) else { return } - try cachedData.write(to: url, options: [.atomic]) - } - - private func cacheURL(for tableName: String) -> Foundation.URL? { - let bundleIdentifier = Bundle.main.bundleIdentifier ?? "net.g-Off.stringray" - let filePath = "\(bundleIdentifier)/\(tableName).localization" - guard let cacheURL = try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: URL(fileURLWithPath: filePath), create: true) - else { - return nil - } - let fileURL = URL(fileURLWithPath: filePath, relativeTo: cacheURL) - try! FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) - return URL(fileURLWithPath: filePath, relativeTo: cacheURL) - } - - private func load(from url: Foundation.URL, options: Options) throws -> OrderedSet { - func lineNumber(scanLocation: Int, newlineLocations: [Int]) -> Int { - var lastIndex = 0 - for (index, newlineLocation) in newlineLocations.enumerated() { - if newlineLocation > scanLocation { - break - } - lastIndex = index - } - return lastIndex - } - - let regex = try NSRegularExpression(pattern: "\"(?.*)\"\\s*=\\s*\"(?.*)\"", options: []) - let baseString = try String(contentsOf: url) - - var newlineLocations: [Int] = [] - if options.contains(.lineNumbers) { - baseString.enumerateSubstrings(in: baseString.startIndex.. [String: StringsTable.DictEntry] { - let data = try Data(contentsOf: url) - let decoder = PropertyListDecoder() - return try decoder.decode([String: StringsTable.DictEntry].self, from: data) - } -} - -private extension OutputStream { - func write(string: String) { - let encodedDataArray = [UInt8](string.utf8) - write(encodedDataArray, maxLength: encodedDataArray.count) - } -} diff --git a/Sources/RayGun/Strings Table/Table.swift b/Sources/RayGun/Strings Table/Table.swift new file mode 100644 index 0000000..84076ff --- /dev/null +++ b/Sources/RayGun/Strings Table/Table.swift @@ -0,0 +1,77 @@ +// +// Table.swift +// +// +// Created by Geoffrey Foster on 2020-02-12. +// + +import Foundation +import Files + +public final class Table { + public let name: String + public let base: String + let path: Folder? + + public private(set) var localizations: [String: Localization] = [:] + + public convenience init(name: String, base: String) { + try! self.init(name: name, base: base, path: nil) + } + + public convenience init(name: String, base: String, path: String, ignoring: Set = []) throws { + try self.init(name: name, base: base, path: try Folder(path: path), ignoring: ignoring) + } + + public convenience init?(base: String) throws { + let file = try File(path: base) + let name = file.nameExcludingExtension + guard let base = file.parent?.nameExcludingExtension else { return nil } + try self.init(name: name, base: base, path: file.parent?.parent) + } + + public init(name: String, base: String, path: Folder?, ignoring: Set = []) throws { + self.name = name + self.base = base + self.path = path + if path != nil { + try load(ignoring: ignoring) + } + } + + public var baseLocalization: Localization { + return localizations[base, default: Localization(name: name, locale: base)] + } + + public func add(localization: Localization) { + if let existingLocalization = localizations[localization.locale] { + existingLocalization.add(localization.strings) + } else { + localizations[localization.locale] = localization + } + } + + public subscript(match: Match) -> [String: Localization] { + var matchingLocalizations: [String: Localization] = [:] + localizations.forEach { (locale, localization) in + let matchingStrings = localization[match] + matchingLocalizations[locale] = Localization(name: name, locale: locale, strings: matchingStrings) + } + return matchingLocalizations + } + + public func save() throws { + guard let path = path else { return } // TODO: throw an error instead + try localizations.forEach { + try $0.value.write(to: path) + } + } + + func load(ignoring: Set) throws { + guard let path = path else { return } + try path.subfolders.filter { $0.extension == "lproj" }.filter { !ignoring.contains($0.nameExcludingExtension) }.forEach { + let localization = try Localization(name: name, folder: $0) + add(localization: localization) + } + } +} diff --git a/Sources/RayGun/Utilities/OrderedSet.swift b/Sources/RayGun/Utilities/OrderedSet.swift deleted file mode 100644 index 188c97c..0000000 --- a/Sources/RayGun/Utilities/OrderedSet.swift +++ /dev/null @@ -1,194 +0,0 @@ -// -// OrderedSet.swift -// stringray -// -// Created by Geoffrey Foster on 2018-11-03. -// - -import Foundation - -public struct OrderedSet: Hashable, ExpressibleByArrayLiteral { - public typealias ArrayLiteralElement = Element - public typealias Index = Int - - private var orderedStorage: [Element] = [] - private var storage: Set = [] - - public init(arrayLiteral elements: OrderedSet.ArrayLiteralElement...) { - self.init(array: elements) - } - - public init(_ array: [Element]) { - self.init(array: array) - } - - public init(array: [Element]) { - for element in array { - append(element) - } - } - - public init() { - orderedStorage = [] - storage = [] - } - - public func contains(_ element: Element) -> Bool { - return storage.contains(element) - } - - public func contains(where predicate: (Element) throws -> Bool) rethrows -> Bool { - return try storage.contains(where: predicate) - } - - public var isEmpty: Bool { - return storage.isEmpty - } -} - -extension OrderedSet: SetAlgebra { - @discardableResult - public mutating func insert(_ newMember: Element) -> (inserted: Bool, memberAfterInsert: Element) { - let result = storage.insert(newMember) - if result.inserted { - orderedStorage.append(newMember) - } - return result - } - - @discardableResult - public mutating func update(with newMember: Element) -> Element? { - if contains(newMember), let index = orderedStorage.firstIndex(of: newMember) { - orderedStorage[index] = newMember - } - let result = storage.update(with: newMember) - if result == nil { - orderedStorage.append(newMember) - } - return result - } - - @discardableResult - public mutating func remove(_ member: Element) -> Element? { - guard let index = orderedStorage.firstIndex(of: member) else { return nil } - orderedStorage.remove(at: index) - storage.remove(member) - return member - } - - public func union(_ other: OrderedSet) -> OrderedSet { - var newSet = self - newSet.formUnion(other) - return newSet - } - - public mutating func formUnion(_ other: OrderedSet) { - for element in other { - append(element) - } - } - - public func intersection(_ other: OrderedSet) -> OrderedSet { - var newSet = self - newSet.formIntersection(other) - return newSet - } - - public mutating func formIntersection(_ other: OrderedSet) { - for item in self where !other.contains(item) { - remove(item) - } - } - - public func symmetricDifference(_ other: OrderedSet) -> OrderedSet { - var newSet = self - newSet.formSymmetricDifference(other) - return newSet - } - - public mutating func formSymmetricDifference(_ other: OrderedSet) { - for member in other { - if contains(member) { - remove(member) - } else { - insert(member) - } - } - } -} - -extension OrderedSet: Codable where Element: Codable { - public init(from decoder: Decoder) throws { - var container = try decoder.unkeyedContainer() - while !container.isAtEnd { - let element = try container.decode(Element.self) - insert(element) - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.unkeyedContainer() - try container.encode(contentsOf: orderedStorage) - } -} - -extension OrderedSet: RandomAccessCollection { - -} - -extension OrderedSet: Sequence { -// func makeIterator() -> IndexingIterator> { -// return orderedStorage.makeIterator() -// } -} - -extension OrderedSet: MutableCollection { - public mutating func sort(by areInIncreasingOrder: (Element, Element) throws -> Bool) rethrows { - try orderedStorage.sort(by: areInIncreasingOrder) - } - - public mutating func append(_ newElement: Element) { - insert(newElement, at: endIndex) - } - - public mutating func insert(_ newElement: Element, at index: Index) { - guard !contains(newElement) else { return } - storage.insert(newElement) - orderedStorage.insert(newElement, at: index) - } - - private mutating func _replace(_ newMember: Element, at index: Index) { - let objectToReplace = orderedStorage[index] - if newMember != objectToReplace && contains(newMember) { - return - } - orderedStorage[index] = newMember - storage.remove(objectToReplace) - storage.insert(newMember) - } - - public subscript(index: Index) -> Element { - get { - return orderedStorage[index] - } - set { - if index == endIndex { - insert(newValue, at: index) - } else { - _replace(newValue, at: index) - } - } - } - - public var startIndex: Index { - return orderedStorage.startIndex - } - - public var endIndex: Index { - return orderedStorage.endIndex - } - - public func index(after i: Index) -> Index { - return i + 1 - } -} diff --git a/Sources/RayGun/Lint Rules/LintRule.swift b/Sources/SillyString/LintRule.swift similarity index 53% rename from Sources/RayGun/Lint Rules/LintRule.swift rename to Sources/SillyString/LintRule.swift index e408c13..a59c8c9 100644 --- a/Sources/RayGun/Lint Rules/LintRule.swift +++ b/Sources/SillyString/LintRule.swift @@ -6,10 +6,18 @@ // import Foundation +import RayGun public protocol LintRule { - var info: RuleInfo { get } - func scan(table: StringsTable, url: Foundation.URL, config: Linter.Config.Rule?) throws -> [LintRuleViolation] + var info: RuleInfo { get } + func scan(table: Table, config: Linter.Config.Rule) throws -> [LintRuleViolation] + func repair(table: Table) throws +} + +public extension LintRule { + func repair(table: Table) throws { + // Default version does nothing + } } public struct RuleInfo { @@ -29,25 +37,12 @@ public enum Severity: String, CustomStringConvertible, Decodable { } public struct LintRuleViolation { - public struct Location: CustomStringConvertible { - public let file: Foundation.URL - public let line: Int? - - public var description: String { - var path = file.lastPathComponent - if let line = line { - path.append(":\(line)") - } - return path - } - } - - public let locale: Locale - public let location: Location + public let locale: String + public let location: Location? public let severity: Severity public let reason: String - public init(locale: Locale, location: Location, severity: Severity, reason: String) { + public init(locale: String, location: Location?, severity: Severity, reason: String) { self.locale = locale self.location = location self.severity = severity diff --git a/Sources/RayGun/Lint Rules/Linter.swift b/Sources/SillyString/Linter.swift similarity index 67% rename from Sources/RayGun/Lint Rules/Linter.swift rename to Sources/SillyString/Linter.swift index b9bbfb2..26c6a0e 100644 --- a/Sources/RayGun/Lint Rules/Linter.swift +++ b/Sources/SillyString/Linter.swift @@ -6,6 +6,7 @@ // import Foundation +import RayGun public struct Linter { public struct Config: Decodable { @@ -41,8 +42,12 @@ public struct Linter { public static let allRules: [LintRule] = [ MissingLocalizationLintRule(), OrphanedLocalizationLintRule(), - MissingPlaceholderLintRule(), - MissingCommentLintRule() + DuplicateKeyLintRule(), + MissingCommentLintRule(), + // Format arguments + ValidFormatArgumentsLintRule(), + MismatchedFormatArgumentLintRule(), + NumberedFormatArgumentsLintRule() ] public struct Error: LocalizedError { @@ -69,7 +74,7 @@ public struct Linter { self.config = config } - private func run(on table: StringsTable, url: Foundation.URL) throws -> [LintRuleViolation] { + private var enabledRules: [LintRule] { var runnableRules = self.rules let includedRules = Set(config.included) @@ -83,18 +88,34 @@ public struct Linter { runnableRules.removeAll { (rule) -> Bool in excludedRules.contains(rule.info.identifier) } - - return try runnableRules.flatMap { - try $0.scan(table: table, url: url, config: config.rules[$0.info.identifier]) + return runnableRules + } + + private func run(on table: Table) throws -> [LintRuleViolation] { + return try enabledRules.flatMap { rule in + try rule.scan(table: table, config: config.rules[rule.info.identifier] ?? Linter.Config.Rule(severity: rule.info.severity)) } } - public func report(on table: StringsTable, url: Foundation.URL) throws { - let violations = try run(on: table, url: url) + public func report(on table: Table) throws { var outputStream = LinterOutputStream(fileHandle: FileHandle.standardOutput) - reporter.generateReport(for: violations, to: &outputStream) - if !violations.isEmpty { - throw Linter.Error(violations) + try enabledRules.forEach { rule in + let violations = try rule.scan(table: table, config: config.rules[rule.info.identifier] ?? Linter.Config.Rule(severity: rule.info.severity)) + if !violations.isEmpty { + reporter.generateReport(for: rule.info, violations: violations, to: &outputStream) + } + } +// let violations = try run(on: table) +// var outputStream = LinterOutputStream(fileHandle: FileHandle.standardOutput) +// reporter.generateReport(for: violations, to: &outputStream) +// if !violations.isEmpty { +// throw Linter.Error(violations) +// } + } + + public func repair(table: Table) throws { + try enabledRules.forEach { rule in + try rule.repair(table: table) } } } diff --git a/Sources/RayGun/Reporters/Reporter.swift b/Sources/SillyString/Reporters/Reporter.swift similarity index 51% rename from Sources/RayGun/Reporters/Reporter.swift rename to Sources/SillyString/Reporters/Reporter.swift index 4fff136..a4c8e29 100644 --- a/Sources/RayGun/Reporters/Reporter.swift +++ b/Sources/SillyString/Reporters/Reporter.swift @@ -8,5 +8,5 @@ import Foundation public protocol Reporter { - func generateReport(for violations: [LintRuleViolation], to outputStream: inout Target) + func generateReport(for rule: RuleInfo, violations: [LintRuleViolation], to outputStream: inout Target) } diff --git a/Sources/SillyString/Rules/DuplicateKeyLintRule.swift b/Sources/SillyString/Rules/DuplicateKeyLintRule.swift new file mode 100644 index 0000000..82cf860 --- /dev/null +++ b/Sources/SillyString/Rules/DuplicateKeyLintRule.swift @@ -0,0 +1,57 @@ +// +// MissingCommentLintRule.swift +// RayGun +// +// Created by Geoffrey Foster on 2020-02-13. +// + +import Foundation +import RayGun + +struct DuplicateKeyLintRule: LintRule { + let info: RuleInfo = RuleInfo(identifier: "duplicate_key", name: "Duplicate Key", description: "", severity: .error) + + func scan(table: Table, config: Linter.Config.Rule) throws -> [LintRuleViolation] { + var violations: [LintRuleViolation] = [] + for (_, localization) in table.localizations { + var pluralKeys: Set = [] + var singularKeys: Set = [] + localization.all.forEach { + switch $0.value { + case .plural: + if !pluralKeys.insert($0.key).inserted { + let violation = LintRuleViolation(locale: table.base, location: $0.location, severity: config.severity, reason: "Duplicate Key") + violations.append(violation) + } + case .text: + if !singularKeys.insert($0.key).inserted { + let violation = LintRuleViolation(locale: table.base, location: $0.location, severity: config.severity, reason: "Duplicate Key") + violations.append(violation) + } + } + } + } + return violations + } + + func repair(table: Table) throws { + for (_, localization) in table.localizations { + var duplicates: Set = [] + var pluralKeys: Set = [] + var singularKeys: Set = [] + localization.all.forEach { + switch $0.value { + case .plural: + if !pluralKeys.insert($0.key).inserted { + duplicates.insert($0) + } + case .text: + if !singularKeys.insert($0.key).inserted { + duplicates.insert($0) + } + } + } + localization.remove(duplicates) + } + } +} diff --git a/Sources/SillyString/Rules/MismatchedFormatArgumentLintRule.swift b/Sources/SillyString/Rules/MismatchedFormatArgumentLintRule.swift new file mode 100644 index 0000000..b1ec565 --- /dev/null +++ b/Sources/SillyString/Rules/MismatchedFormatArgumentLintRule.swift @@ -0,0 +1,55 @@ +// +// MismatchedFormatArgumentLintRule.swift +// stringray +// +// Created by Geoffrey Foster on 2018-11-20. +// + +import Foundation +import RayGun +import PrintfParser + +struct MismatchedFormatArgumentLintRule: LintRule { + let info: RuleInfo = RuleInfo(identifier: "mismatched_format_argument", name: "Mismatched Format Argument", description: "", severity: .error) + + func scan(table: Table, config: Linter.Config.Rule) throws -> [LintRuleViolation] { + var violations: [LintRuleViolation] = [] + var placeholders: [String: [PlaceholderType]] = [:] + var formatSpecs: [String: [Spec]] = [:] + try table.baseLocalization.strings.forEach { localizedString in + placeholders[localizedString.key] = try PlaceholderType.orderedPlaceholders(from: localizedString.text) + formatSpecs[localizedString.key] = try localizedString.text.formatSpecifiers() + } + + try table.localizations.forEach { (locale, localization) in + try localization.strings.forEach { localizedString in + if let baseSpecs = formatSpecs[localizedString.key] { + let localizedSpecs = try localizedString.text.formatSpecifiers() + + } + if let basePlaceholder = placeholders[localizedString.key] { + let placeholder = try PlaceholderType.orderedPlaceholders(from: localizedString.text) + if placeholder != basePlaceholder { + let reason = "Mismatched placeholders \(localizedString.key)" + let violation = LintRuleViolation(locale: locale, location: localizedString.location, severity: config.severity, reason: reason) + violations.append(violation) + } + } + } + } + + return violations + } + + private func compare(base: [Spec], other: [Spec]) { + + var baseMap: [Int8?: [Spec]] = [:] + base.forEach { + baseMap[$0.mainArgNum, default: []].append($0) + } + + if base.first(where: { $0.mainArgNum != nil}) != nil { + + } + } +} diff --git a/Sources/SillyString/Rules/MissingCommentLintRule.swift b/Sources/SillyString/Rules/MissingCommentLintRule.swift new file mode 100644 index 0000000..cb80fc9 --- /dev/null +++ b/Sources/SillyString/Rules/MissingCommentLintRule.swift @@ -0,0 +1,22 @@ +// +// MissingCommentLintRule.swift +// RayGun +// +// Created by Geoffrey Foster on 2019-06-02. +// + +import Foundation +import RayGun + +struct MissingCommentLintRule: LintRule { + let info: RuleInfo = RuleInfo(identifier: "missing_comment", name: "Missing Comment", description: "", severity: .error) + + func scan(table: Table, config: Linter.Config.Rule) throws -> [LintRuleViolation] { + var violations: [LintRuleViolation] = [] + for string in table.baseLocalization.strings where string.comment == nil { + let violation = LintRuleViolation(locale: table.base, location: string.location, severity: config.severity, reason: "Missing comment") + violations.append(violation) + } + return violations + } +} diff --git a/Sources/SillyString/Rules/MissingLocalizationLintRule.swift b/Sources/SillyString/Rules/MissingLocalizationLintRule.swift new file mode 100644 index 0000000..732c2f4 --- /dev/null +++ b/Sources/SillyString/Rules/MissingLocalizationLintRule.swift @@ -0,0 +1,27 @@ +// +// MissingLocalizationLintRule.swift +// stringray +// +// Created by Geoffrey Foster on 2018-11-07. +// + +import Foundation +import RayGun + +struct MissingLocalizationLintRule: LintRule { + let info: RuleInfo = RuleInfo(identifier: "missing_localization", name: "Missing Localization", description: "", severity: .warning) + + func scan(table: Table, config: Linter.Config.Rule) throws -> [LintRuleViolation] { + var violations: [LintRuleViolation] = [] + let baseKeys = table.baseLocalization.allKeys + for (locale, localization) in table.localizations where locale != table.base { + for missingKey in baseKeys.subtracting(localization.allKeys) { + let location = Location(file: "") + let reason = "Missing \(missingKey)" + let violation = LintRuleViolation(locale: locale, location: location, severity: config.severity, reason: reason) + violations.append(violation) + } + } + return violations + } +} diff --git a/Sources/SillyString/Rules/NumberedFormatArgumentsLintRule.swift b/Sources/SillyString/Rules/NumberedFormatArgumentsLintRule.swift new file mode 100644 index 0000000..ac79b77 --- /dev/null +++ b/Sources/SillyString/Rules/NumberedFormatArgumentsLintRule.swift @@ -0,0 +1,37 @@ +// +// NumberedFormatArgumentsLintRule.swift +// +// +// Created by Geoffrey Foster on 2020-03-18. +// + +import Foundation +import RayGun +import PrintfParser + +struct NumberedFormatArgumentsLintRule: LintRule { + let info: RuleInfo = RuleInfo(identifier: "numbered_format_argument", name: "Numbered Format Arguments", description: "Validates that any localized string with more than one format argument includes numeric positions.", severity: .warning) + + func scan(table: Table, config: Linter.Config.Rule) throws -> [LintRuleViolation] { + var violations: [LintRuleViolation] = [] + var formatSpecs: [String: [Spec]] = [:] + try table.baseLocalization.strings.forEach { localizedString in + formatSpecs[localizedString.key] = try localizedString.text.formatSpecifiers() + } + + table.localizations.forEach { (locale, localization) in + localization.all.forEach { localizedString in + guard let specs = try? localizedString.text.formatSpecifiers() else { return } + guard specs.count > 1 else { return } + let missingSpecs = specs.filter { $0.mainArgNum == nil && !$0.flags.contains(.externalSpec) } + if !missingSpecs.isEmpty { + let reason = "Missing numbered positions in format string for key: \(localizedString.key)" + let violation = LintRuleViolation(locale: locale, location: localizedString.location, severity: config.severity, reason: reason) + violations.append(violation) + } + } + } + + return violations + } +} diff --git a/Sources/SillyString/Rules/OrphanedLocalizationLintRule.swift b/Sources/SillyString/Rules/OrphanedLocalizationLintRule.swift new file mode 100644 index 0000000..9c62a00 --- /dev/null +++ b/Sources/SillyString/Rules/OrphanedLocalizationLintRule.swift @@ -0,0 +1,36 @@ +// +// OrphanedLocalizationLintRule.swift +// stringray +// +// Created by Geoffrey Foster on 2018-11-07. +// + +import Foundation +import RayGun + +struct OrphanedLocalizationLintRule: LintRule { + let info: RuleInfo = RuleInfo(identifier: "orphaned_localization", name: "Orphaned Localization", description: "", severity: .warning) + + func scan(table: Table, config: Linter.Config.Rule) throws -> [LintRuleViolation] { + var violations: [LintRuleViolation] = [] + let baseKeys = table.baseLocalization.allKeys + for (locale, localization) in table.localizations where locale != table.base { + for orphanKey in localization.allKeys.subtracting(baseKeys) { + let location = Location(file: "") + let reason = "Orphaned \(orphanKey)" + let violation = LintRuleViolation(locale: locale, location: location, severity: config.severity, reason: reason) + violations.append(violation) + } + } + return violations + } + + func repair(table: Table) throws { + let baseKeys = table.baseLocalization.allKeys + for (locale, localization) in table.localizations where locale != table.base { + let orphanKeys = localization.allKeys.subtracting(baseKeys) + localization.removeAll { orphanKeys.contains($0.key) } + } + try table.save() + } +} diff --git a/Sources/SillyString/Rules/ValidFormatArgumentsLintRule.swift b/Sources/SillyString/Rules/ValidFormatArgumentsLintRule.swift new file mode 100644 index 0000000..111a596 --- /dev/null +++ b/Sources/SillyString/Rules/ValidFormatArgumentsLintRule.swift @@ -0,0 +1,32 @@ +// +// ValidFormatArgumentsLintRule.swift +// +// +// Created by Geoffrey Foster on 2020-03-19. +// + +import Foundation +import RayGun +import PrintfParser + +struct ValidFormatArgumentsLintRule: LintRule { + let info: RuleInfo = RuleInfo(identifier: "valid_format_argument", name: "Valid Format Arguments", description: "Validates that all format arguments are valid.", severity: .error) + + func scan(table: Table, config: Linter.Config.Rule) throws -> [LintRuleViolation] { + var violations: [LintRuleViolation] = [] + + table.localizations.forEach { (locale, localization) in + localization.all.forEach { localizedString in + do { + _ = try localizedString.text.formatSpecifiers() + } catch { + let reason = "Invalid format argumeent for key: \(localizedString.key)" + let violation = LintRuleViolation(locale: locale, location: localizedString.location, severity: config.severity, reason: reason) + violations.append(violation) + } + } + } + + return violations + } +} diff --git a/Sources/stringray/Commands/Copy.swift b/Sources/stringray/Commands/Copy.swift new file mode 100644 index 0000000..1f38194 --- /dev/null +++ b/Sources/stringray/Commands/Copy.swift @@ -0,0 +1,52 @@ +// +// CopyCommand.swift +// stringray +// +// Created by Geoffrey Foster on 2018-12-05. +// + +import Foundation +import RayGun +import ArgumentParser +import Files + +struct Copy: ParsableCommand { + static var configuration = CommandConfiguration(abstract: "Copy keys matching the given pattern from one strings table to another.") + + @OptionGroup() + var inputs: Input + + @OptionGroup() + var outputs: Output + + @Option(help: "Locale to copy to/from.\nIf not specified then matching strings from all locales will be copied to the destination table.") + var locale: String? + + @Option(help: "Prefix to match against that will be copied to the destination table.\nIf not specified then all strings will be copied.") + var prefix: [String] + + @Option(help: "Exact string to match against that will be copied to the destination table.\nIf not specified then all strings will be copied.") + var exact: [String] + + func run() throws { + let matching = prefix.map { Match.prefix($0) } + exact.map { Match.exact($0) } + if let locale = locale { + try Operation.copy.perform( + inputs: inputs, + outputs: outputs, + locale: locale, + matching: matching + ) + } else { + let table = try inputs.loadTable() + try table.localizations.forEach { (locale, localization) in + try Operation.copy.perform( + inputs: inputs, + outputs: outputs, + locale: locale, + matching: matching + ) + } + } + } +} diff --git a/Sources/stringray/Commands/CopyCommand.swift b/Sources/stringray/Commands/CopyCommand.swift deleted file mode 100644 index 5d71d5c..0000000 --- a/Sources/stringray/Commands/CopyCommand.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// CopyCommand.swift -// stringray -// -// Created by Geoffrey Foster on 2018-12-05. -// - -import Foundation -import CommandRegistry -import Basic -import Utility -import RayGun - -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 loader.write(to: to.resourceDirectory, table: toTable) - } -} diff --git a/Sources/stringray/Commands/Delete.swift b/Sources/stringray/Commands/Delete.swift new file mode 100644 index 0000000..f7aeb58 --- /dev/null +++ b/Sources/stringray/Commands/Delete.swift @@ -0,0 +1,59 @@ +// +// Delete.swift +// +// +// Created by Geoffrey Foster on 2020-03-05. +// + +import Foundation +import RayGun +import ArgumentParser +import Files + +struct Delete: ParsableCommand { + static var configuration = CommandConfiguration(abstract: "Copy keys matching the given pattern from one strings table to another.") + + @OptionGroup() + var inputs: Input + + @Option(help: "Locale to delete from.\nIf not specified then matching strings from all locales will be deleted.") + var locale: String? + + @Option(help: "Prefix to match against that will be deleted.") + var prefix: [String] + + @Option(help: "Exact string to match against that will be deleted.") + var exact: [String] + + private var matching: [Match] { + return prefix.map { Match.prefix($0) } + exact.map { Match.exact($0) } + } + + func validate() throws { + guard !matching.isEmpty else { + throw ArgumentParser.ValidationError("At least one of prefix or exact must be specified.") + } + } + + func run() throws { + let outputs = Output(destination: inputs.source, output: inputs.input) + if let locale = locale { + try Operation.delete.perform( + inputs: inputs, + outputs: outputs, + locale: locale, + matching: matching + ) + } else { + let table = try inputs.loadTable() + try table.localizations.forEach { (locale, localization) in + try Operation.delete.perform( + inputs: inputs, + outputs: outputs, + locale: locale, + matching: matching + ) + } + } + } +} diff --git a/Sources/stringray/Commands/Helpers/Input.swift b/Sources/stringray/Commands/Helpers/Input.swift new file mode 100644 index 0000000..9a47bc3 --- /dev/null +++ b/Sources/stringray/Commands/Helpers/Input.swift @@ -0,0 +1,52 @@ +// +// Input.swift +// +// +// Created by Geoffrey Foster on 2020-02-28. +// + +import ArgumentParser +import Files +import RayGun +import Foundation + +struct Input: ParsableCommand { + @Argument(help: "The name of the source strings table.") + var source: String + + @Option(default: Folder.current, help: "Input directory.\nIf not specified then defaults to the current directory.") + var input: Folder + + @Option(help: "Base locale.\nIf not specified then a set of heuristics will be used to attempt to resolve the base.") + var base: String? + + @Option(parsing: .singleValue, help: "Locales to ignore.") + var ignore: [String] + + func loadTable() throws -> Table { + return try Table(name: source, base: try computedBase(), path: input.path, ignoring: Set(ignore)) + } + + func loadLocalization(_ locale: String) throws -> Localization { + return try Localization(name: source, folder: folder(for: locale)) + } + + func folder(for locale: String) throws -> Folder { + let lproj = "\(locale).lproj" + return try input.subfolder(named: lproj) + } + + private func computedBase() throws -> String { + let baseLocale: String + if let base = base { + baseLocale = base + } else if input.containsSubfolder(named: "Base.lproj") { + baseLocale = "Base" + } else if let languageCode = Locale.current.languageCode, input.containsSubfolder(named: "\(languageCode).lproj") { + baseLocale = languageCode + } else { + throw ArgumentParser.ValidationError("Base locale could not be inferred.") + } + return baseLocale + } +} diff --git a/Sources/stringray/Commands/Helpers/Operation.swift b/Sources/stringray/Commands/Helpers/Operation.swift new file mode 100644 index 0000000..4bf67b5 --- /dev/null +++ b/Sources/stringray/Commands/Helpers/Operation.swift @@ -0,0 +1,38 @@ +// +// Operation.swift +// +// +// Created by Geoffrey Foster on 2020-03-05. +// + +import Foundation +import RayGun +import Files + +enum Operation { + case copy + case move + case delete + + func perform(inputs: Input, outputs: Output, locale: String, matching: [Match]) throws { + let lproj = "\(locale).lproj" + let sourceLocalization = try inputs.loadLocalization(locale) + let outputFolder = try outputs.output.createSubfolderIfNeeded(at: lproj) + let destinationLocalization = try Localization(name: outputs.destination, folder: outputFolder) + + let copiedStrings: [LocalizedString] + if matching.isEmpty { + copiedStrings = sourceLocalization.all + } else { + copiedStrings = matching.flatMap { sourceLocalization[$0] } + } + + destinationLocalization.add(copiedStrings) + try destinationLocalization.write(to: outputFolder) + + if self == .move || self == .delete { + sourceLocalization.remove(Set(copiedStrings)) + try sourceLocalization.write(to: inputs.folder(for: locale)) + } + } +} diff --git a/Sources/stringray/Commands/Helpers/Output.swift b/Sources/stringray/Commands/Helpers/Output.swift new file mode 100644 index 0000000..82c8c87 --- /dev/null +++ b/Sources/stringray/Commands/Helpers/Output.swift @@ -0,0 +1,36 @@ +// +// Output.swift +// +// +// Created by Geoffrey Foster on 2020-03-04. +// + +import ArgumentParser +import Files +import RayGun +import Foundation + +struct Output: ParsableCommand { + @Argument(help: "The name of the destination strings table.") + var destination: String + + @Option(default: Folder.current, help: "Output directory.\nIf not specified then defaults to the current directory.") + var output: Folder + + init() {} + + init(destination: String, output: Folder) { + self.destination = destination + self.output = output + } + + func loadLocalization(_ locale: String) throws -> Localization { + let outputFolder = try folder(for: locale) + return try Localization(name: destination, folder: outputFolder) + } + + func folder(for locale: String) throws -> Folder { + let lproj = "\(locale).lproj" + return try output.createSubfolderIfNeeded(at: lproj) + } +} diff --git a/Sources/stringray/Commands/Lint.swift b/Sources/stringray/Commands/Lint.swift new file mode 100644 index 0000000..26a4398 --- /dev/null +++ b/Sources/stringray/Commands/Lint.swift @@ -0,0 +1,115 @@ +// +// LintCommand.swift +// stringray +// +// Created by Geoffrey Foster on 2018-11-07. +// + +import Foundation +import RayGun +import SillyString +import SwiftyTextTable +import ArgumentParser +import Files +import Yams + +struct Lint: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Checks for warnings or errors on strings tables.", + subcommands: [Run.self, List.self, Repair.self], + defaultSubcommand: Run.self + ) +} + +extension Lint { + static func config(_ config: File?) throws -> Linter.Config { + let linterConfig: Linter.Config + + if let config = config { + let string = try config.readAsString() + linterConfig = try YAMLDecoder().decode(Linter.Config.self, from: string, userInfo: [:]) + } else { + linterConfig = Linter.Config() + } + return linterConfig + } + + struct Run: ParsableCommand { + @OptionGroup() + var inputs: Input + + @Option(help: "Configuration file. Defaults to /\(Linter.fileName)") + var config: File? + + func run() throws { + let reporter: Reporter = ConsoleReporter() + let linter = Linter(reporter: reporter, config: try Lint.config(config)) + let table = try inputs.loadTable() + + var violations: [LintRuleViolation] = [] + do { + print("Linting: \(table.name)") + try linter.report(on: table) + } catch let error as Linter.Error { + violations.append(contentsOf: error.violations) + } + + if !violations.isEmpty { + throw Linter.Error(violations) + } + } + } + + struct Repair: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Attempt to repair the table." + ) + + @OptionGroup() + var inputs: Input + + @Option(help: "Configuration file. Defaults to /\(Linter.fileName)") + var config: File? + + func run() throws { + let table = try inputs.loadTable() + + let linter = Linter(reporter: NullReporter(), config: try Lint.config(config)) + try linter.repair(table: table) + } + } + + struct List: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Lists the available rules and default configuration." + ) + + func run() throws { + let linter = Linter(reporter: ConsoleReporter()) + let rules = linter.rules + let columns = [ + TextTableColumn(header: "id"), + TextTableColumn(header: "name"), + TextTableColumn(header: "description") + ] + var table = TextTable(columns: columns) + rules.forEach { + table.addRow(values: + [ + $0.info.identifier, + $0.info.name, + $0.info.description + ] + ) + } + print(table.render()) + } + } +} + +extension Linter.Config { + public init(url: Foundation.URL) throws { + let string = try String(contentsOf: url, encoding: .utf8) + self = try YAMLDecoder().decode(Linter.Config.self, from: string, userInfo: [:]) + } +} diff --git a/Sources/stringray/Commands/LintCommand.swift b/Sources/stringray/Commands/LintCommand.swift deleted file mode 100644 index 46041c0..0000000 --- a/Sources/stringray/Commands/LintCommand.swift +++ /dev/null @@ -1,178 +0,0 @@ -// -// LintCommand.swift -// stringray -// -// Created by Geoffrey Foster on 2018-11-07. -// - -import Foundation -import CommandRegistry -import Basic -import Utility -import RayGun -import XcodeProject -import SwiftyTextTable - -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" - let overview: String = "Checks for warnings or errors on the given strings table." - - private let binder: ArgumentBinder - - init(parser: ArgumentParser) { - binder = ArgumentBinder() - let subparser = parser.add(subparser: command, overview: overview) - - let filesUsage = "Specify a list of file paths to the string files to run lint on; If omitted, this will default to the current folder" - let files = subparser.add(positional: "files", kind: [PathArgument].self, optional: true, usage: filesUsage, completion: .filename) - binder.bind(positional: files) { (arguments, files) in - arguments.inputFile = files.map { $0.path } - } - - let listRules = subparser.add(option: "--list", shortName: "-l", kind: Bool.self, usage: "List available rules and default configuration", completion: .none) - 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 { - var commandArgs = Arguments() - try binder.fill(parseResult: arguments, into: &commandArgs) - - if commandArgs.listRules { - listRules() - return - } - - let config: Linter.Config - if let configFile = commandArgs.configFile ?? localFileSystem.currentWorkingDirectory?.appending(component: Linter.fileName), localFileSystem.exists(configFile) { - let url = URL(fileURLWithPath: configFile.asString) - config = try Linter.Config(url: url) - } else { - config = Linter.Config() - } - - let lintInput: [LintInput] - var reporter: Reporter = ConsoleReporter() - if commandArgs.inputFile.isEmpty { - 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(inputs: lintInput, reporter: reporter, config: config) - } - - 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() { - let linter = Linter(reporter: ConsoleReporter()) - let rules = linter.rules - let columns = [ - TextTableColumn(header: "id"), - TextTableColumn(header: "name"), - TextTableColumn(header: "description") - ] - var table = TextTable(columns: columns) - rules.forEach { - table.addRow(values: - [ - $0.info.identifier, - $0.info.name, - $0.info.description - ] - ) - } - print(table.render()) - } - - private func lint(inputs: [LintInput], reporter: Reporter, config: Linter.Config) throws { - let loader = StringsTableLoader(options: [.lineNumbers]) - let linter = Linter(reporter: reporter, config: config) - var violations: [LintRuleViolation] = [] - - try inputs.forEach { - print("Linting: \($0.tableName)") - let table = try loader.load(url: $0.resourceURL, name: $0.tableName, base: $0.locale) - do { - try linter.report(on: table, url: $0.resourceURL) - try loader.writeCache(table: table, baseURL: $0.resourceURL) - } catch let error as Linter.Error { - violations.append(contentsOf: error.violations) - } - } - if !violations.isEmpty { - throw Linter.Error(violations) - } - } -} diff --git a/Sources/stringray/Commands/Move.swift b/Sources/stringray/Commands/Move.swift new file mode 100644 index 0000000..8f3aef0 --- /dev/null +++ b/Sources/stringray/Commands/Move.swift @@ -0,0 +1,51 @@ +// +// MoveCommand.swift +// stringray +// +// Created by Geoffrey Foster on 2018-11-04. +// + +import Foundation +import ArgumentParser +import RayGun + +struct Move: ParsableCommand { + static var configuration = CommandConfiguration(abstract: "Moves keys matching the given pattern from one strings table to another.") + + @OptionGroup() + var inputs: Input + + @OptionGroup() + var outputs: Output + + @Option(help: "Locale to move to/from.\nIf not specified then matching strings from all locales will be copied to the destination table.") + var locale: String? + + @Option(help: "Prefix to match against that will be moved to the destination table.\nIf not specified then all strings will be moved.") + var prefix: [String] + + @Option(help: "Exact string to match against that will be moved to the destination table.\nIf not specified then all strings will be moved.") + var exact: [String] + + func run() throws { + let matching = prefix.map { Match.prefix($0) } + exact.map { Match.exact($0) } + if let locale = locale { + try Operation.move.perform( + inputs: inputs, + outputs: outputs, + locale: locale, + matching: matching + ) + } else { + let table = try inputs.loadTable() + try table.localizations.forEach { (locale, localization) in + try Operation.move.perform( + inputs: inputs, + outputs: outputs, + locale: locale, + matching: matching + ) + } + } + } +} diff --git a/Sources/stringray/Commands/MoveCommand.swift b/Sources/stringray/Commands/MoveCommand.swift deleted file mode 100644 index 766140f..0000000 --- a/Sources/stringray/Commands/MoveCommand.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// MoveCommand.swift -// stringray -// -// Created by Geoffrey Foster on 2018-11-04. -// - -import Foundation -import CommandRegistry -import Basic -import Utility -import RayGun - -struct MoveCommand: Command { - private struct Arguments { - var inputFile: Foundation.URL! - var outputFile: Foundation.URL! - var matching: [Match] = [] - } - let command: String = "move" - let overview: String = "Moves 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 move(from: commandArgs.inputFile, to: commandArgs.outputFile, matching: commandArgs.matching) - } - - private func move(from: Foundation.URL, to: Foundation.URL, matching: [Match]) throws { - let loader = StringsTableLoader() - var fromTable = try loader.load(url: from) - var toTable = try loader.load(url: to) - - let filteredTable = fromTable.withKeys(matching: matching) - toTable.addEntries(from: filteredTable) - fromTable.removeEntries(from: filteredTable) - try loader.write(to: to.resourceDirectory, table: toTable) - try loader.write(to: from.resourceDirectory, table: fromTable) - } -} diff --git a/Sources/stringray/Commands/Rename.swift b/Sources/stringray/Commands/Rename.swift new file mode 100644 index 0000000..dcba661 --- /dev/null +++ b/Sources/stringray/Commands/Rename.swift @@ -0,0 +1,22 @@ +// +// RenameCommand.swift +// stringray +// +// Created by Geoffrey Foster on 2018-11-06. +// + +import Foundation +import ArgumentParser +import RayGun + +struct Rename: ParsableCommand { + static var configuration = CommandConfiguration(abstract: "Renames a key with another key.") + + func validate() throws { + throw ArgumentParser.ValidationError("This command isn't yet implemented.") + } + + func run() throws { + + } +} diff --git a/Sources/stringray/Commands/RenameCommand.swift b/Sources/stringray/Commands/RenameCommand.swift deleted file mode 100644 index ae02496..0000000 --- a/Sources/stringray/Commands/RenameCommand.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// RenameCommand.swift -// stringray -// -// Created by Geoffrey Foster on 2018-11-06. -// - -import Foundation -import CommandRegistry -import Basic -import Utility -import RayGun - -struct RenameCommand: Command { - private struct Arguments { - var inputFile: Foundation.URL! - var matching: [Match] = [] - var replacements: [String] = [] - } - - let command: String = "rename" - let overview: String = "Renames a key with another key." - - 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 prefix = subparser.add(option: "--prefix", shortName: "-p", kind: [String].self, strategy: .oneByOne, usage: "", completion: nil) - let replacement = subparser.add(option: "--replacement", shortName: "-r", kind: [String].self, strategy: .oneByOne, usage: "", completion: nil) - - binder.bind(positional: inputFile) { (arguments, inputFile) in - arguments.inputFile = URL(fileURLWithPath: inputFile.path.asString) - } - binder.bind(option: prefix) { (arguments, matching) in - arguments.matching = matching.map { - return .prefix($0) - } - } - binder.bind(option: replacement) { (arguments, replacements) in - arguments.replacements = replacements - } - } - - func run(with arguments: ArgumentParser.Result) throws { - var commandArgs = Arguments() - try binder.fill(parseResult: arguments, into: &commandArgs) - try rename(url: commandArgs.inputFile, matching: commandArgs.matching, replacements: commandArgs.replacements) - } - - private func rename(url: Foundation.URL, matching: [Match], replacements replacementStrings: [String]) throws { - let loader = StringsTableLoader() - var table = try loader.load(url: url) - table.replace(matches: matching, replacements: replacementStrings) - try loader.write(to: url.resourceDirectory, table: table) - } -} diff --git a/Sources/stringray/Commands/Sort.swift b/Sources/stringray/Commands/Sort.swift new file mode 100644 index 0000000..190344f --- /dev/null +++ b/Sources/stringray/Commands/Sort.swift @@ -0,0 +1,38 @@ +// +// SortCommand.swift +// stringray +// +// Created by Geoffrey Foster on 2018-11-04. +// + +import Foundation +import RayGun +import ArgumentParser +import Files + +struct Sort: ParsableCommand { + static var configuration = CommandConfiguration(abstract: "Sorts the given strings table alphabetically by key.") + + @OptionGroup() + var inputs: Input + + @Option(help: "Locale to sort\nIf not specified then strings from all locales will be sorted.") + var locale: String? + + func run() throws { + if let locale = locale { + let localization = try inputs.loadLocalization(locale) + try sort(localization: localization) + } else { + let table = try inputs.loadTable() + try table.localizations.forEach { (key, localization) in + try sort(localization: localization) + } + } + } + + private func sort(localization: Localization) throws { + localization.sort() + try localization.write(to: inputs.input) + } +} diff --git a/Sources/stringray/Commands/SortCommand.swift b/Sources/stringray/Commands/SortCommand.swift deleted file mode 100644 index 0ed38a9..0000000 --- a/Sources/stringray/Commands/SortCommand.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// SortCommand.swift -// stringray -// -// Created by Geoffrey Foster on 2018-11-04. -// - -import Foundation -import RayGun -import CommandRegistry -import Basic -import Utility - -struct SortCommand: Command { - private struct Arguments { - var inputFile: Foundation.URL! - var allLocales: Bool = false - } - let command: String = "sort" - let overview: String = "Sorts the given strings table alphabetically by key." - - 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 allLocales = subparser.add(option: "--all-locales", shortName: "-a", kind: Bool.self, usage: "Loads all locales under the given base one to be sorted") - - binder.bind(positional: inputFile) { (arguments, inputFile) in - arguments.inputFile = URL(fileURLWithPath: inputFile.path.asString) - } - binder.bind(option: allLocales) { (arguments, allLocales) in - arguments.allLocales = allLocales - } - } - - func run(with arguments: ArgumentParser.Result) throws { - var commandArgs = Arguments() - try binder.fill(parseResult: arguments, into: &commandArgs) - try sort(url: commandArgs.inputFile, allLocales: commandArgs.allLocales) - } - - private func sort(url: Foundation.URL, allLocales: Bool) throws { - var options:StringsTableLoader.Options = [.ignoreCached] - if !allLocales { - options.insert(.singleLocale) - } - let loader = StringsTableLoader(options: options) - var table = try loader.load(url: url) - table.sort() - try loader.write(to: url.resourceDirectory, table: table) - } -} diff --git a/Sources/stringray/Commands/Stringray.swift b/Sources/stringray/Commands/Stringray.swift new file mode 100644 index 0000000..7115037 --- /dev/null +++ b/Sources/stringray/Commands/Stringray.swift @@ -0,0 +1,24 @@ +// +// Stringray.swift +// +// +// Created by Geoffrey Foster on 2020-02-27. +// + +import Foundation +import ArgumentParser + +struct Stringray: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Deal with your localized string resources.", + subcommands: [ + Copy.self, + Delete.self, + Lint.self, + Move.self, +// Rename.self, + Sort.self, + VersionCommand.self + ] + ) +} diff --git a/Sources/stringray/Commands/Version.swift b/Sources/stringray/Commands/Version.swift new file mode 100644 index 0000000..ce269c0 --- /dev/null +++ b/Sources/stringray/Commands/Version.swift @@ -0,0 +1,21 @@ +// +// Version.swift +// +// +// Created by Geoffrey Foster on 2020-03-05. +// + +import Foundation +import ArgumentParser +import Version + +@available(macOS 10.15, *) +struct VersionCommand: ParsableCommand { + static let configuration = CommandConfiguration(commandName: "version", abstract: "Prints the current version.") + func run() throws { + if let bundleVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, + let version = Version(bundleVersion) { + print(version) + } + } +} diff --git a/Sources/stringray/Files+ExpressibleByArgument.swift b/Sources/stringray/Files+ExpressibleByArgument.swift new file mode 100644 index 0000000..312dbc0 --- /dev/null +++ b/Sources/stringray/Files+ExpressibleByArgument.swift @@ -0,0 +1,21 @@ +// +// Files+ExpressibleByArgument.swift +// +// +// Created by Geoffrey Foster on 2020-02-27. +// + +import Files +import ArgumentParser + +extension File: ExpressibleByArgument { + public init?(argument: String) { + try? self.init(path: argument) + } +} + +extension Folder: ExpressibleByArgument { + public init?(argument: String) { + try? self.init(path: argument) + } +} diff --git a/Sources/stringray/Reporters/ConsoleReporter.swift b/Sources/stringray/Reporters/ConsoleReporter.swift index bb4b022..cfe5f37 100644 --- a/Sources/stringray/Reporters/ConsoleReporter.swift +++ b/Sources/stringray/Reporters/ConsoleReporter.swift @@ -6,12 +6,12 @@ // import Foundation -import RayGun +import SillyString import SwiftyTextTable struct ConsoleReporter: Reporter { - func generateReport(for violations: [LintRuleViolation], to outputStream: inout Target) { - outputStream.write(violations.renderTextTable()) + func generateReport(for rule: RuleInfo, violations: [LintRuleViolation], to outputStream: inout Target) { + outputStream.write(TextTable(objects: violations, header: rule.name).render()) } } @@ -21,6 +21,6 @@ extension LintRuleViolation: TextTableRepresentable { } public var tableValues: [CustomStringConvertible] { - return [locale.identifier, location, severity, reason] + return [locale, location ?? "", severity, reason] } } diff --git a/Sources/stringray/Reporters/NullReporter.swift b/Sources/stringray/Reporters/NullReporter.swift new file mode 100644 index 0000000..e7ee3ee --- /dev/null +++ b/Sources/stringray/Reporters/NullReporter.swift @@ -0,0 +1,15 @@ +// +// NullReporter.swift +// +// +// Created by Geoffrey Foster on 2020-03-08. +// + +import Foundation +import SillyString + +struct NullReporter: Reporter { + func generateReport(for rule: RuleInfo, violations: [LintRuleViolation], to outputStream: inout Target) { + + } +} diff --git a/Sources/stringray/Reporters/XcodeReporter.swift b/Sources/stringray/Reporters/XcodeReporter.swift index 197974c..3dbee96 100644 --- a/Sources/stringray/Reporters/XcodeReporter.swift +++ b/Sources/stringray/Reporters/XcodeReporter.swift @@ -6,10 +6,10 @@ // import Foundation -import RayGun +import SillyString struct XcodeReporter: Reporter { - func generateReport(for violations: [LintRuleViolation], to outputStream: inout Target) { + func generateReport(for rule: RuleInfo, violations: [LintRuleViolation], to outputStream: inout Target) { for violation in violations { outputStream.write(violation.xcode) } @@ -20,7 +20,8 @@ fileprivate extension LintRuleViolation { /// Outputs in an Xcode compatible way /// - {full_path_to_file}{:line}{:character}: {error,warning}: {content} var xcode: String { - var output = location.file.path + guard let location = location else { return "" } + var output = location.file if let line = location.line { output.append(":\(line)") } diff --git a/Sources/stringray/Version.swift b/Sources/stringray/Version.swift deleted file mode 100644 index 1598837..0000000 --- a/Sources/stringray/Version.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Utility - -extension Version { - static var current: Version = "0.4.0" -} diff --git a/Sources/stringray/main.swift b/Sources/stringray/main.swift index 9833a2b..a131677 100644 --- a/Sources/stringray/main.swift +++ b/Sources/stringray/main.swift @@ -7,22 +7,23 @@ // import Foundation -import Utility -import CommandRegistry -import Yams -import RayGun +import SillyString -extension Linter.Config { - public init(url: Foundation.URL) throws { - let string = try String(contentsOf: url, encoding: .utf8) - self = try YAMLDecoder().decode(Linter.Config.self, from: string, userInfo: [:]) - } -} +let running = false -var registry = Registry(usage: " ", overview: "", version: Version.current) -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) -registry.run() +#if testing +Stringray.main() +#else +//FileManager.default.changeCurrentDirectoryPath(...) + +var additionalArgs: [String] = [] + +//additionalArgs = ["sort", "--help"] +//additionalArgs = ["copy", "--help"] +//additionalArgs = ["lint", "--help"] +additionalArgs = ["version"] + +var args = CommandLine.arguments.dropFirst() +Stringray.main(args + additionalArgs) + +#endif diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index 8646953..e69de29 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -1,7 +0,0 @@ -import XCTest - -import stringrayTests - -var tests = [XCTestCaseEntry]() -tests += stringrayTests.allTests() -XCTMain(tests) \ No newline at end of file diff --git a/Tests/RayGunTests/LocalizationTests.swift b/Tests/RayGunTests/LocalizationTests.swift new file mode 100644 index 0000000..18aeacf --- /dev/null +++ b/Tests/RayGunTests/LocalizationTests.swift @@ -0,0 +1,22 @@ +// +// LocalizationTests.swift +// +// +// Created by Geoffrey Foster on 2020-02-13. +// + +import XCTest +@testable import RayGun + +class LocalizationTests: XCTestCase { + func testReplace() { + let key = "this.that" + let localization = Localization(name: "Test", locale: "en") + localization.add(key: "\(key).hi", value: "Hi", comment: "Comment") + localization.add(key: "\(key).there", value: "There", comment: "Comment") + + localization.replace(matches: [.prefix(key)], replacements: ["greet"]) + + print(localization) + } +} diff --git a/Tests/RayGunTests/TableTests.swift b/Tests/RayGunTests/TableTests.swift new file mode 100644 index 0000000..2e3d3fc --- /dev/null +++ b/Tests/RayGunTests/TableTests.swift @@ -0,0 +1,48 @@ +// +// TableTests.swift +// +// +// Created by Geoffrey Foster on 2020-02-10. +// + +import XCTest +@testable import RayGun + +class TableTests: XCTestCase { + func testStringLoad() throws { + let string = """ +/* This is a sample */ +"key" = "value"; +""" + XCTAssertNoThrow(try LocalizedString.parse(string: string)) + } + + func testStringDictLoad() throws { + let string = """ + + + + + timeline.toast.error.tooManyCommentAttachments + + NSStringLocalizedFormatKey + Comments can't have more than %1$d %#@d_num_attachments@. + d_num_attachments + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + attachment + other + attachments + + + + + """ + let data = try XCTUnwrap(string.data(using: .utf8)) + XCTAssertNoThrow(try LocalizedString.load(data: data)) + } +} diff --git a/Tests/SillyStringTests/DuplicateKeyLintRuleTests.swift b/Tests/SillyStringTests/DuplicateKeyLintRuleTests.swift new file mode 100644 index 0000000..1acefee --- /dev/null +++ b/Tests/SillyStringTests/DuplicateKeyLintRuleTests.swift @@ -0,0 +1,26 @@ +// +// DuplicateKeyLintRuleTests.swift +// +// +// Created by Geoffrey Foster on 2020-02-15. +// + +import XCTest +import RayGun +@testable import SillyString + +class DuplicateKeyLintRuleTests: XCTestCase { + let rule = DuplicateKeyLintRule() + + func testDuplicateKey() throws { + let baseLocale = "en" + let key = "key" + let english = Localization(name: "Test", locale: baseLocale) + english.add(key: key, value: "Hi", comment: "Comment") + english.add(key: key, value: "There", comment: "Comment") + let table = Table(name: "Test", base: baseLocale) + table.add(localization: english) + let violations = try rule.scan(table: table, config: .init(severity: rule.info.severity)) + XCTAssertEqual(violations.count, 1) + } +} diff --git a/Tests/SillyStringTests/MismatchedFormatArgumentLintRuleTests.swift b/Tests/SillyStringTests/MismatchedFormatArgumentLintRuleTests.swift new file mode 100644 index 0000000..288d919 --- /dev/null +++ b/Tests/SillyStringTests/MismatchedFormatArgumentLintRuleTests.swift @@ -0,0 +1,31 @@ +// +// MismatchedFormatArgumentLintRuleTests.swift +// +// +// Created by Geoffrey Foster on 2020-02-15. +// + +import XCTest +import RayGun +@testable import SillyString + +class MismatchedFormatArgumentLintRuleTests: XCTestCase { + let rule = MismatchedFormatArgumentLintRule() + + func testMissingLocalization() throws { + let baseLocale = "en" + + let key = "key" + let english = Localization(name: "Test", locale: baseLocale) + english.add(key: key, value: "%1$@ mentioned you on %2$@ at %3$@", comment: "Comment") + let french = Localization(name: "Test", locale: "fr") + french.add(key: key, value: "%1$@ mentioned you on %2$@", comment: "Comment") + + let table = Table(name: "Test", base: baseLocale) + table.add(localization: english) + table.add(localization: french) + + let violations = try rule.scan(table: table, config: .init(severity: rule.info.severity)) + XCTAssertEqual(violations.count, 1) + } +} diff --git a/Tests/SillyStringTests/MissingCommentLintRuleTests.swift b/Tests/SillyStringTests/MissingCommentLintRuleTests.swift new file mode 100644 index 0000000..2b19e34 --- /dev/null +++ b/Tests/SillyStringTests/MissingCommentLintRuleTests.swift @@ -0,0 +1,25 @@ +// +// MissingCommentLintRuleTests.swift +// +// +// Created by Geoffrey Foster on 2020-02-15. +// + +import XCTest +import RayGun +@testable import SillyString + +class MissingCommentLintRuleTests: XCTestCase { + let rule = MissingCommentLintRule() + + func testMissingComment() throws { + let baseLocale = "en" + let key = "key" + let english = Localization(name: "Test", locale: baseLocale) + english.add(key: key, value: "Hi", comment: nil) + let table = Table(name: "Test", base: baseLocale) + table.add(localization: english) + let violations = try rule.scan(table: table, config: .init(severity: rule.info.severity)) + XCTAssertEqual(violations.count, 1) + } +} diff --git a/Tests/SillyStringTests/MissingLocalizationLintRuleTests.swift b/Tests/SillyStringTests/MissingLocalizationLintRuleTests.swift new file mode 100644 index 0000000..45690c9 --- /dev/null +++ b/Tests/SillyStringTests/MissingLocalizationLintRuleTests.swift @@ -0,0 +1,27 @@ +// +// MissingLocalizationLintRuleTests.swift +// +// +// Created by Geoffrey Foster on 2020-02-15. +// + +import XCTest +import RayGun +@testable import SillyString + +class MissingLocalizationLintRuleTests: XCTestCase { + let rule = MissingLocalizationLintRule() + + func testMissingLocalization() throws { + let baseLocale = "en" + let key = "key" + let english = Localization(name: "Test", locale: baseLocale) + english.add(key: key, value: "%1$@ mentioned you on %2$@ at %3$@", comment: "Comment") + let table = Table(name: "Test", base: baseLocale) + table.add(localization: english) + table.add(localization: Localization(name: "Test", locale: "fr")) + + let violations = try rule.scan(table: table, config: .init(severity: rule.info.severity)) + XCTAssertEqual(violations.count, 1) + } +} diff --git a/Tests/SillyStringTests/NumberedFormatArgumentsLintRuleTests.swift b/Tests/SillyStringTests/NumberedFormatArgumentsLintRuleTests.swift new file mode 100644 index 0000000..6b5a760 --- /dev/null +++ b/Tests/SillyStringTests/NumberedFormatArgumentsLintRuleTests.swift @@ -0,0 +1,28 @@ +// +// NumberedFormatArgumentsLintRuleTests.swift +// +// +// Created by Geoffrey Foster on 2020-03-18. +// + +import XCTest +import RayGun +@testable import SillyString + +class NumberedFormatArgumentsLintRuleTests: XCTestCase { + let rule = NumberedFormatArgumentsLintRule() + + func testOrphanedLocalization() throws { + let baseLocale = "en" + + let key = "key" + let english = Localization(name: "Test", locale: baseLocale) + english.add(key: key, value: "%@ mentioned you on %@ at %@", comment: "Comment") + + let table = Table(name: "Test", base: baseLocale) + table.add(localization: english) + + let violations = try rule.scan(table: table, config: .init(severity: rule.info.severity)) + XCTAssertEqual(violations.count, 1) + } +} diff --git a/Tests/SillyStringTests/OrphanedLocalizationLintRuleTests.swift b/Tests/SillyStringTests/OrphanedLocalizationLintRuleTests.swift new file mode 100644 index 0000000..e0a54fa --- /dev/null +++ b/Tests/SillyStringTests/OrphanedLocalizationLintRuleTests.swift @@ -0,0 +1,29 @@ +// +// OrphanedLocalizationLintRuleTests.swift +// +// +// Created by Geoffrey Foster on 2020-02-15. +// + +import XCTest +import RayGun +@testable import SillyString + +class OrphanedLocalizationLintRuleTests: XCTestCase { + let rule = OrphanedLocalizationLintRule() + + func testOrphanedLocalization() throws { + let baseLocale = "fr" + + let key = "key" + let english = Localization(name: "Test", locale: "en") + english.add(key: key, value: "%1$@ mentioned you on %2$@ at %3$@", comment: "Comment") + + let table = Table(name: "Test", base: baseLocale) + table.add(localization: Localization(name: "Test", locale: baseLocale)) + table.add(localization: english) + + let violations = try rule.scan(table: table, config: .init(severity: rule.info.severity)) + XCTAssertEqual(violations.count, 1) + } +} diff --git a/Tests/SillyStringTests/ValidFormatArgumentsLintRuleTests.swift b/Tests/SillyStringTests/ValidFormatArgumentsLintRuleTests.swift new file mode 100644 index 0000000..bebc7e6 --- /dev/null +++ b/Tests/SillyStringTests/ValidFormatArgumentsLintRuleTests.swift @@ -0,0 +1,27 @@ +// +// ValidFormatArgumentsLintRuleTests.swift +// +// +// Created by Geoffrey Foster on 2020-03-19. +// + +import XCTest +import RayGun +@testable import SillyString + +class ValidFormatArgumentsLintRuleTests: XCTestCase { + let rule = ValidFormatArgumentsLintRule() + + func testOrphanedLocalization() throws { + let baseLocale = "en" + + let english = Localization(name: "Test", locale: baseLocale) + english.add(key: "1", value: "%") + + let table = Table(name: "Test", base: baseLocale) + table.add(localization: english) + + let violations = try rule.scan(table: table, config: .init(severity: rule.info.severity)) + XCTAssertEqual(violations.count, 1) + } +} diff --git a/Tests/stringrayTests/LinterTests.swift b/Tests/stringrayTests/LinterTests.swift deleted file mode 100644 index 6b33472..0000000 --- a/Tests/stringrayTests/LinterTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// LinterTests.swift -// stringrayTests -// -// Created by Geoffrey Foster on 2019-02-01. -// - -import XCTest -@testable import RayGun - -class LinterTests: XCTestCase { - struct TestReporter: Reporter { - func generateReport(for violations: [LintRuleViolation], to outputStream: inout Target) where Target : TextOutputStream { - - } - } - func testLint() { - var entries = StringsTable.EntriesType() - let baseLocale = Locale(identifier: "en") - let otherLocale = Locale(identifier: "fr") - let key = "key" - entries[baseLocale, default: OrderedSet()].append( - StringsTable.Entry(location: nil, comment: nil, key: key, value: "%1$@ mentioned you on %2$@ at %3$@") - ) - entries[otherLocale, default: OrderedSet()].append( - StringsTable.Entry(location: nil, comment: nil, key: key, value: "%1$@ mentioné youé oné %1$@ até %1$@") - ) - let table = StringsTable(name: "Test", base: baseLocale, entries: entries) - let linter = Linter(reporter: TestReporter()) - do { - try linter.report(on: table, url: URL(fileURLWithPath: "file://does/not/exist")) - } catch { - print("hello") - } - } -} diff --git a/Tests/stringrayTests/XCTestManifests.swift b/Tests/stringrayTests/XCTestManifests.swift deleted file mode 100644 index d552201..0000000 --- a/Tests/stringrayTests/XCTestManifests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import XCTest - -#if !os(macOS) -public func allTests() -> [XCTestCaseEntry] { - return [ - testCase(stringrayTests.allTests), - ] -} -#endif \ No newline at end of file diff --git a/Tests/stringrayTests/stringrayTests.swift b/Tests/stringrayTests/stringrayTests.swift deleted file mode 100644 index 134bd54..0000000 --- a/Tests/stringrayTests/stringrayTests.swift +++ /dev/null @@ -1,47 +0,0 @@ -import XCTest -import class Foundation.Bundle - -final class stringrayTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - - // Some of the APIs that we use below are available in macOS 10.13 and above. - guard #available(macOS 10.13, *) else { - return - } - - let fooBinary = productsDirectory.appendingPathComponent("stringray") - - let process = Process() - process.executableURL = fooBinary - - let pipe = Pipe() - process.standardOutput = pipe - - try process.run() - process.waitUntilExit() - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8) - - XCTAssertEqual(output, "Hello, world!\n") - } - - /// Returns path to the built products directory. - var productsDirectory: URL { - #if os(macOS) - for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { - return bundle.bundleURL.deletingLastPathComponent() - } - fatalError("couldn't find the products directory") - #else - return Bundle.main.bundleURL - #endif - } - - static var allTests = [ - ("testExample", testExample), - ] -}