diff --git a/README.md b/README.md index 8b640e5..2053e21 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ Ensure your string catalog changes are always up-to-snuff with your team's local ## Usage -### CLI +### Running +#### CLI With a [config file](.xcstringslint.yaml) located in the same directory as the executable @@ -16,11 +17,60 @@ swift run xcstringslint Sources/StringCatalogValidator/Resources/Localizable.xcs To specify a config file append `--config [config file path]` -### GitHub Actions +#### GitHub Actions See [this repository's actions for an example](.github/workflows/lint.yaml) -## Example +### Configuration + +#### Rules + +##### `require-locale` + +Ensures that for each locale has a translation present in the `.xcstrings` file. + +e.g. ensure that each key has a translation for `en` and `fr` +```yaml +rules: + require-locale: + values: + - en + - fr +``` + +##### `require-localization-state` + +Ensures that each key has a localization state matching the provided values. + +e.g. ensure that each key has a localization state of `translated` +```yaml +rules: + require-localization-state: + value: translated +``` + +To negate this rule use `reject-localization-state` instead. + +##### `require-extraction-state` + +Ensures that each key has a translation for each extraction state. + +e.g. ensure that each key was automatically extracted +```yaml +rules: + require-extraction-state: + value: "automatic" +``` + +To negate this rule use `reject-extraction-state` instead. + +#### Ignoring Keys + +To ignore all validation for a particular key, either mark them as "Don't Translate" or include `[no-lint]` in the key's comment. + +To ignore a specific rule, include `[no-lint:rule-name]` in the key's comment, e.g. `[no-lint:require-locale]` + +## Example Output ``` $ swift run xcstringslint Sources/StringCatalogValidator/Resources/Localizable.xcstrings --require-locale en fr diff --git a/Sources/StringCatalogDecodable/StringCatalog.swift b/Sources/StringCatalogDecodable/StringCatalog.swift index 6b4add2..c5bea26 100644 --- a/Sources/StringCatalogDecodable/StringCatalog.swift +++ b/Sources/StringCatalogDecodable/StringCatalog.swift @@ -9,6 +9,14 @@ public struct StringCatalog: Decodable { public var localizations: [String: Localization]? public var comment: String? public var extractionState: String? + public var shouldTranslate: Bool? + + public init(localizations: [String : Localization]? = nil, comment: String? = nil, extractionState: String? = nil, shouldTranslate: Bool? = nil) { + self.localizations = localizations + self.comment = comment + self.extractionState = extractionState + self.shouldTranslate = shouldTranslate + } } } @@ -40,6 +48,11 @@ public enum Localization: Decodable { public struct StringUnit: Decodable { public var state: String public var value: String + + public init(state: String, value: String) { + self.state = state + self.value = value + } } } diff --git a/Sources/StringCatalogValidator/Ignore.swift b/Sources/StringCatalogValidator/Ignore.swift index b099304..a57326a 100644 --- a/Sources/StringCatalogValidator/Ignore.swift +++ b/Sources/StringCatalogValidator/Ignore.swift @@ -18,8 +18,21 @@ public struct Ignore: IgnoreProtocol { extension Ignore { public static let `default` = Ignore { _, rule, value in - value.comment?.contains("[no-lint]") - ?? value.comment?.contains("[no-lint:\(rule)]") - ?? false + if let comment = value.comment { + if comment.contains("[no-lint]") { + return true + } + if comment.contains("[no-lint:\(rule)]") { + return true + } + } + + if let shouldTranslate = value.shouldTranslate { + if shouldTranslate == false { + return true + } + } + + return false } } diff --git a/Sources/StringCatalogValidator/Resources/Localizable.xcstrings b/Sources/StringCatalogValidator/Resources/Localizable.xcstrings index 897aa19..fe8e009 100644 --- a/Sources/StringCatalogValidator/Resources/Localizable.xcstrings +++ b/Sources/StringCatalogValidator/Resources/Localizable.xcstrings @@ -63,7 +63,8 @@ "value" : "no translation state found" } } - } + }, + "shouldTranslate" : false }, "Rejects a localization if its state matches one of the provided values. Know localization states: %@" : { "localizations" : { diff --git a/Sources/StringCatalogValidator/Rule.swift b/Sources/StringCatalogValidator/Rule.swift index e3e525e..e69b7e2 100644 --- a/Sources/StringCatalogValidator/Rule.swift +++ b/Sources/StringCatalogValidator/Rule.swift @@ -14,13 +14,16 @@ public protocol Rule { extension Rule { func fail(message: String) -> [Failure] { - [ - Validator.Reason(rule: self, message: message) - ] + [fail(message: message)] } func fail(message: String) -> Failure { - Validator.Reason(rule: self, message: message) + Validator.Reason( + name: Self.name, + description: Self.description, + severity: severity, + message: message + ) } var success: [Failure] { diff --git a/Sources/StringCatalogValidator/Validator.swift b/Sources/StringCatalogValidator/Validator.swift index fad40bc..febed3a 100644 --- a/Sources/StringCatalogValidator/Validator.swift +++ b/Sources/StringCatalogValidator/Validator.swift @@ -50,8 +50,10 @@ extension Validator { public let validations: [Reason] } - public struct Reason { - public let rule: Rule + public struct Reason: Equatable { + public let name: String + public let description: String + public let severity: Severity public let message: String } } diff --git a/Sources/XCStringsLint/XCStringsLint+Lint.swift b/Sources/XCStringsLint/XCStringsLint+Lint.swift index 55b2154..023e9e3 100644 --- a/Sources/XCStringsLint/XCStringsLint+Lint.swift +++ b/Sources/XCStringsLint/XCStringsLint+Lint.swift @@ -3,7 +3,7 @@ import StringCatalogValidator import StringCatalogDecodable import ArgumentParser -extension XCStringsLint { +extension xcstringslint { struct Lint: ParsableCommand { static var configuration = CommandConfiguration( commandName: "lint" @@ -44,7 +44,7 @@ extension XCStringsLint { // MARK: - Config Resolving -extension XCStringsLint.Lint { +extension xcstringslint.Lint { private func findXCStringsFiles(atPath path: String) throws -> [String] { let enumerator = FileManager.default diff --git a/Sources/XCStringsLint/XCStringsLint+Rules.swift b/Sources/XCStringsLint/XCStringsLint+Rules.swift index 2ae1ead..70e8cd7 100644 --- a/Sources/XCStringsLint/XCStringsLint+Rules.swift +++ b/Sources/XCStringsLint/XCStringsLint+Rules.swift @@ -1,6 +1,6 @@ import ArgumentParser -extension XCStringsLint { +extension xcstringslint { struct Rules: ParsableCommand { static var configuration = CommandConfiguration(commandName: "rules") diff --git a/Sources/XCStringsLint/XCStringsLint.swift b/Sources/XCStringsLint/XCStringsLint.swift index 75620a3..2b575ab 100644 --- a/Sources/XCStringsLint/XCStringsLint.swift +++ b/Sources/XCStringsLint/XCStringsLint.swift @@ -4,8 +4,10 @@ import StringCatalogDecodable import ArgumentParser @main -struct XCStringsLint: ParsableCommand { +struct xcstringslint: ParsableCommand { static var configuration = CommandConfiguration( + abstract: "Validate xcstrings", + discussion: "Ensure string catalog changes always meet your team's localization requirements.", subcommands: [Lint.self, Rules.self], defaultSubcommand: Lint.self ) diff --git a/Tests/StringCatalogValidatorTests/IgnoreTests.swift b/Tests/StringCatalogValidatorTests/IgnoreTests.swift new file mode 100644 index 0000000..8a528cf --- /dev/null +++ b/Tests/StringCatalogValidatorTests/IgnoreTests.swift @@ -0,0 +1,64 @@ +import XCTest +import StringCatalogValidator + +class IgnoreTests: XCTestCase { + func test_givenShouldTranslateIsFalse_whenIgnoreIsCalled_falseIsReturned() { + let sut = Ignore.default + + let result = sut.ignore( + key: "", + rule: "", + value: .init(shouldTranslate: false) + ) + + XCTAssertTrue(result) + } + + func test_givenShouldTranslateIsTrue_whenIgnoreIsCalled_falseIsReturned() { + let sut = Ignore.default + + let result = sut.ignore( + key: "", + rule: "", + value: .init(shouldTranslate: true) + ) + + XCTAssertFalse(result) + } + + func test_givenCommentContainsNoLint_whenIgnoreIsCalled_trueIsReturned() { + let sut = Ignore.default + + let result = sut.ignore( + key: "", + rule: "", + value: .init(comment: "[no-lint]") + ) + + XCTAssertTrue(result) + } + + func test_givenCommentContainsNoLint_whenTheSpecializedRuleMatches_falseIsReturned() { + let sut = Ignore.default + + let result = sut.ignore( + key: "", + rule: "rule", + value: .init(comment: "[no-lint:rule]") + ) + + XCTAssertTrue(result) + } + + func test_givenCommentContainsNoLint_whenTheSpecializedRuleDoesNotMatch_trueIsReturned() { + let sut = Ignore.default + + let result = sut.ignore( + key: "", + rule: "rule", + value: .init(comment: "[no-lint:another-rule]") + ) + + XCTAssertFalse(result) + } +} diff --git a/Tests/StringCatalogValidatorTests/RejectExtractionStateTests.swift b/Tests/StringCatalogValidatorTests/RejectExtractionStateTests.swift index cab3fdc..9b8b09d 100644 --- a/Tests/StringCatalogValidatorTests/RejectExtractionStateTests.swift +++ b/Tests/StringCatalogValidatorTests/RejectExtractionStateTests.swift @@ -43,7 +43,7 @@ class RejectExtractionStateTests: XCTestCase { let json = "{}" let result = sut.validate(key: "key", value: try EntryDecoder.entry(from: json)) - XCTAssertEqual(result.map(\.rule), ["reject-extraction-state"]) + XCTAssertEqual(result.map(\.name), ["reject-extraction-state"]) } @@ -57,6 +57,6 @@ class RejectExtractionStateTests: XCTestCase { """ let result = sut.validate(key: "key", value: try EntryDecoder.entry(from: json)) - XCTAssertEqual(result.map(\.rule), ["reject-extraction-state"]) + XCTAssertEqual(result.map(\.name), ["reject-extraction-state"]) } } diff --git a/Tests/StringCatalogValidatorTests/RequireExtractionStateTests.swift b/Tests/StringCatalogValidatorTests/RequireExtractionStateTests.swift index 00db706..c80c9e8 100644 --- a/Tests/StringCatalogValidatorTests/RequireExtractionStateTests.swift +++ b/Tests/StringCatalogValidatorTests/RequireExtractionStateTests.swift @@ -36,7 +36,7 @@ class RequireExtractionStateTests: XCTestCase { """ let result = sut.validate(key: "key", value: try EntryDecoder.entry(from: json)) - XCTAssertEqual(result.map(\.rule), ["require-extraction-state"]) + XCTAssertEqual(result.map(\.name), ["require-extraction-state"]) } func testRequireExtractionStateMatchesValue_failsForOther() throws { @@ -49,7 +49,7 @@ class RequireExtractionStateTests: XCTestCase { """ let result = sut.validate(key: "key", value: try EntryDecoder.entry(from: json)) - XCTAssertEqual(result.map(\.rule), ["require-extraction-state"]) + XCTAssertEqual(result.map(\.name), ["require-extraction-state"]) } func testRequireExtractionStateMatchesAutomatic_fail() throws { @@ -62,6 +62,6 @@ class RequireExtractionStateTests: XCTestCase { """ let result = sut.validate(key: "key", value: try EntryDecoder.entry(from: json)) - XCTAssertEqual(result.map(\.rule), ["require-extraction-state"]) + XCTAssertEqual(result.map(\.name), ["require-extraction-state"]) } } diff --git a/Tests/StringCatalogValidatorTests/RequireLocaleTests.swift b/Tests/StringCatalogValidatorTests/RequireLocaleTests.swift index 54824f6..113c6eb 100644 --- a/Tests/StringCatalogValidatorTests/RequireLocaleTests.swift +++ b/Tests/StringCatalogValidatorTests/RequireLocaleTests.swift @@ -39,7 +39,7 @@ class RequireLocaleTests: XCTestCase { """ let result = sut.validate(key: "key", value: try EntryDecoder.entry(from: json)) - XCTAssertEqual(result.map(\.rule), ["require-locale"]) + XCTAssertEqual(result.map(\.name), ["require-locale"]) } func testRequireLocale_givenNoVariations_multipleLocales_andFullMatch_succeeds() throws { @@ -91,7 +91,7 @@ class RequireLocaleTests: XCTestCase { """ let result = sut.validate(key: "key", value: try EntryDecoder.entry(from: json)) - XCTAssertEqual(result.map(\.rule), ["require-locale"]) + XCTAssertEqual(result.map(\.name), ["require-locale"]) } func testRequireLocale_givenVariations_andMatchingLocale_succeeds() throws { @@ -155,6 +155,6 @@ class RequireLocaleTests: XCTestCase { """ let result = sut.validate(key: "key", value: try EntryDecoder.entry(from: json)) - XCTAssertEqual(result.map(\.rule), ["require-locale"]) + XCTAssertEqual(result.map(\.name), ["require-locale"]) } } diff --git a/Tests/StringCatalogValidatorTests/RequireLocalizationStateTests.swift b/Tests/StringCatalogValidatorTests/RequireLocalizationStateTests.swift index af19ec9..b3b99c0 100644 --- a/Tests/StringCatalogValidatorTests/RequireLocalizationStateTests.swift +++ b/Tests/StringCatalogValidatorTests/RequireLocalizationStateTests.swift @@ -48,7 +48,7 @@ class RequireLocalizationStateTests: XCTestCase { """ let result = sut.validate(key: "key", value: try EntryDecoder.entry(from: json)) - XCTAssertEqual(result.map(\.rule), ["require-localization-state"]) + XCTAssertEqual(result.map(\.name), ["require-localization-state"]) } func test_RequireLocalizationState_withVariations_andMatchingState_succeeds() throws { @@ -112,6 +112,6 @@ class RequireLocalizationStateTests: XCTestCase { """ let result = sut.validate(key: "key", value: try EntryDecoder.entry(from: json)) - XCTAssertEqual(result.map(\.rule), ["require-localization-state"]) + XCTAssertEqual(result.map(\.name), ["require-localization-state"]) } }