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..4907e16 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: "RayGunTests",
+ dependencies: ["RayGun"]
+ ),
+ .testTarget(
+ name: "SillyStringTests",
+ dependencies: ["SillyString"]
+ ),
.testTarget(
name: "stringrayTests",
- dependencies: ["RayGun"]),
- ]
+ 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/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/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/LocalizationTests.swift b/Tests/stringrayTests/LocalizationTests.swift
new file mode 100644
index 0000000..4f5d7e2
--- /dev/null
+++ b/Tests/stringrayTests/LocalizationTests.swift
@@ -0,0 +1,23 @@
+//
+// LocalizationTests.swift
+//
+//
+// Created by Geoffrey Foster on 2020-02-13.
+//
+
+import XCTest
+@testable import RayGun
+@testable import SillyString
+
+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/stringrayTests/TableTests.swift b/Tests/stringrayTests/TableTests.swift
new file mode 100644
index 0000000..2e3d3fc
--- /dev/null
+++ b/Tests/stringrayTests/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/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),
- ]
-}