From e52e2b236f309d1d9037a20d17dd4a8c1ca63ec0 Mon Sep 17 00:00:00 2001 From: Konstantin Yurichev Date: Thu, 18 Dec 2025 18:48:03 +0100 Subject: [PATCH 1/4] Find unused keys --- .../Checker/SourceFileBatchChecker.swift | 60 +++++++++++++++---- .../Checker/SourceFileChecker.swift | 6 ++ .../LocalizeChecker/Checker/UnusedKey.swift | 6 ++ .../Data source/LocalizeBundle.swift | 6 +- .../Parser/LocalizeEntry.swift | 2 +- .../SourceFileBatchCheckerTests.swift | 52 +++++++++++++++- 6 files changed, 118 insertions(+), 14 deletions(-) create mode 100644 Sources/LocalizeChecker/Checker/UnusedKey.swift diff --git a/Sources/LocalizeChecker/Checker/SourceFileBatchChecker.swift b/Sources/LocalizeChecker/Checker/SourceFileBatchChecker.swift index 329ccd6..9b0e376 100644 --- a/Sources/LocalizeChecker/Checker/SourceFileBatchChecker.swift +++ b/Sources/LocalizeChecker/Checker/SourceFileBatchChecker.swift @@ -4,7 +4,9 @@ import Foundation public final class SourceFileBatchChecker { public typealias ReportStream = AsyncThrowingStream - + public typealias UnusedKeysStream = AsyncThrowingStream + typealias ReportMessages = (errors: [ErrorMessage], unused: [UnusedKeyMessage], used: [LocalizeEntry]) + @available(macOS 12, *) /// Async stream of obtained check reports public var reports: ReportStream { @@ -12,10 +14,11 @@ public final class SourceFileBatchChecker { try run() } } - - @available(macOS, deprecated: 12, obsoleted: 13, message: "Use much faster reports stream") - public func getReports() throws -> [ErrorMessage] { - try syncRun() + + public var unusedKeys: [UnusedKeyMessage] { + get async throws { + try await runForUnusedKeys() + } } @available(macOS 12, *) @@ -62,7 +65,7 @@ public final class SourceFileBatchChecker { let localizeBundle = try LocalizeBundle(directoryPath: localizeBundleUrl.path) return ReportStream { continuation in Task { - await withThrowingTaskGroup(of: [ErrorMessage].self) { group in + await withThrowingTaskGroup(of: ReportMessages.self) { group in for filesChunk in chunks { group.addTask { try self.processBatch( @@ -73,7 +76,7 @@ public final class SourceFileBatchChecker { } do { - for try await reportsChunk in group { + for try await (reportsChunk, _, _) in group { reportsChunk.forEach { continuation.yield($0) } @@ -86,9 +89,40 @@ public final class SourceFileBatchChecker { } } } - + + @available(macOS 12, *) + @discardableResult + func runForUnusedKeys() async throws -> [UnusedKeyMessage] { + let localizeBundle = try LocalizeBundle(directoryPath: localizeBundleUrl.path) + return try await Task { + try await withThrowingTaskGroup(of: ReportMessages.self) { group in + for filesChunk in chunks { + group.addTask { + try self.processBatch( + ofSourceFiles: Array(filesChunk), + in: localizeBundle + ) + } + } + + var usedKeys: Set = [] + var unusedKeys: Set = [] + for try await (_, unusedKeysChunk, usedKeysChunk) in group { + unusedKeysChunk.forEach { + unusedKeys.insert($0.key) + } + usedKeysChunk.forEach { + usedKeys.insert($0.key) + } + } + let trulyUnusedKeys = unusedKeys.subtracting(usedKeys) + return Array(trulyUnusedKeys.map(UnusedKeyMessage.init(key:))) + } + }.value + } + @discardableResult - func syncRun() throws -> [ErrorMessage] { + func syncRun() throws -> ReportMessages { let localizeBundle = LocalizeBundle(fileUrl: localizeBundleUrl) let reports = try self.processBatch( ofSourceFiles: sourceFiles, @@ -98,7 +132,7 @@ public final class SourceFileBatchChecker { return reports } - private func processBatch(ofSourceFiles files: [String], in localizeBundle: LocalizeBundle) throws -> [ErrorMessage] { + private func processBatch(ofSourceFiles files: [String], in localizeBundle: LocalizeBundle) throws -> ReportMessages { let fileUrls = files.compactMap(URL.init(fileURLWithPath:)) let sourceCheckers = try fileUrls.map { try SourceFileChecker(fileUrl: $0, localizeBundle: localizeBundle) @@ -107,7 +141,11 @@ public final class SourceFileBatchChecker { try sourceChecker.start() } - return sourceCheckers.flatMap(\.errors) + return ( + sourceCheckers.flatMap(\.errors), + sourceCheckers.flatMap(\.unusedKeys).map(UnusedKeyMessage.init(key:)), + sourceCheckers.flatMap(\.usedKeys) + ) } } diff --git a/Sources/LocalizeChecker/Checker/SourceFileChecker.swift b/Sources/LocalizeChecker/Checker/SourceFileChecker.swift index f9042c9..cd8dd83 100644 --- a/Sources/LocalizeChecker/Checker/SourceFileChecker.swift +++ b/Sources/LocalizeChecker/Checker/SourceFileChecker.swift @@ -5,6 +5,8 @@ import SwiftParser final class SourceFileChecker { var errors: [ErrorMessage] = [] + var usedKeys: [LocalizeEntry] = [] + var unusedKeys: [String] = [] private let fileUrl: URL private let bundle: LocalizeBundle @@ -30,6 +32,10 @@ final class SourceFileChecker { errors = parser.foundKeys .filter(notExistsInBundle) .compactMap(\.errorMessage) + usedKeys = parser.foundKeys + unusedKeys = bundle.keys.filter { key in + !parser.foundKeys.contains(where: { $0.key == key }) + } } } diff --git a/Sources/LocalizeChecker/Checker/UnusedKey.swift b/Sources/LocalizeChecker/Checker/UnusedKey.swift new file mode 100644 index 0000000..d3dbc99 --- /dev/null +++ b/Sources/LocalizeChecker/Checker/UnusedKey.swift @@ -0,0 +1,6 @@ +import Foundation + +public struct UnusedKeyMessage: Equatable, Hashable, Codable { + /// Key of the localized string in the dictionary + public let key: String +} diff --git a/Sources/LocalizeChecker/Data source/LocalizeBundle.swift b/Sources/LocalizeChecker/Data source/LocalizeBundle.swift index 9ec9ad8..083a154 100644 --- a/Sources/LocalizeChecker/Data source/LocalizeBundle.swift +++ b/Sources/LocalizeChecker/Data source/LocalizeBundle.swift @@ -51,7 +51,11 @@ public final class LocalizeBundle { public subscript(key: String) -> Any? { dictionary[key] } - + + public var keys: [String] { + Array(dictionary.keys) + } + } // MARK:- Parsing diff --git a/Sources/LocalizeChecker/Parser/LocalizeEntry.swift b/Sources/LocalizeChecker/Parser/LocalizeEntry.swift index 4900e83..6ff4c7a 100644 --- a/Sources/LocalizeChecker/Parser/LocalizeEntry.swift +++ b/Sources/LocalizeChecker/Parser/LocalizeEntry.swift @@ -1,7 +1,7 @@ import Foundation import SwiftSyntax -struct LocalizeEntry { +struct LocalizeEntry: Hashable { let key: String let sourceLocation: SourceLocation } diff --git a/Tests/LocalizeChecker/SourceFileBatchCheckerTests.swift b/Tests/LocalizeChecker/SourceFileBatchCheckerTests.swift index 12ba230..7e2a3ce 100644 --- a/Tests/LocalizeChecker/SourceFileBatchCheckerTests.swift +++ b/Tests/LocalizeChecker/SourceFileBatchCheckerTests.swift @@ -162,5 +162,55 @@ extension SourceFileBatchCheckerTests { // Then XCTAssertTrue(reports.isEmpty) } - + + func testAllProcessedFilesUseOnlyOneKey() async throws { + // Given + let stringsBundleUrl = Bundle.module.resourceURL?.appendingPathComponent("Fixtures/enlproj") + let filesIdRange = 0...20 + let files = filesIdRange.map(filePath) + for id in filesIdRange { + setup( + input: inputSource( + withLocalizeKey: "alert_ok"), + fileId: id + ) + } + XCTAssertNotNil(stringsBundleUrl) + let checker = SourceFileBatchChecker( + sourceFiles: files, + localizeBundleFile: stringsBundleUrl! + ) + + // When + let unusedKeys: [UnusedKeyMessage] = try await checker.unusedKeys + + // Then + XCTAssertEqual(unusedKeys.count, 5435) + } + + func testAllProcessedFilesDontUseAnyKeys() async throws { + // Given + let stringsBundleUrl = Bundle.module.resourceURL?.appendingPathComponent("Fixtures/enlproj") + let filesIdRange = 0...20 + let files = filesIdRange.map(filePath) + for id in filesIdRange { + setup( + input: inputSource( + withLocalizeKey: "do_you_know_me"), + fileId: id + ) + } + XCTAssertNotNil(stringsBundleUrl) + let checker = SourceFileBatchChecker( + sourceFiles: files, + localizeBundleFile: stringsBundleUrl! + ) + + // When + let unusedKeys: [UnusedKeyMessage] = try await checker.unusedKeys + + // Then + XCTAssertEqual(unusedKeys.count, 5436) + } + } From df080201bd3b55127babf1ddc17966a0448b48ca Mon Sep 17 00:00:00 2001 From: Konstantin Yurichev Date: Thu, 18 Dec 2025 18:57:19 +0100 Subject: [PATCH 2/4] Fix access level imports --- Package.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Package.swift b/Package.swift index 4a872c4..6e48c17 100644 --- a/Package.swift +++ b/Package.swift @@ -28,6 +28,9 @@ let package = Package( .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftParser", package: "swift-syntax"), "LocalizeChecker", + ], + swiftSettings: [ + .enableUpcomingFeature("AccessLevelOnImport") ] ), .target( From decee7b7ae97d089d142cb550c7ee3d4adefd006 Mon Sep 17 00:00:00 2001 From: Konstantin Yurichev Date: Thu, 18 Dec 2025 19:15:55 +0100 Subject: [PATCH 3/4] Support Swift 6 --- .github/workflows/swift.yml | 4 ++-- Package.swift | 7 ++++--- .../Checker/ErrorMessage.swift | 2 +- .../Checker/SourceFileBatchChecker.swift | 7 ++++--- .../Checker/SourceFileChecker.swift | 4 ++-- .../LocalizeChecker/Checker/UnusedKey.swift | 2 +- .../Data source/LocalizeBundle.swift | 20 ++++++++++--------- .../Parser/LocalizeEntry.swift | 4 ++-- .../Formatters/XcodeReportFormatter.swift | 2 +- .../Reporter/ReportPrinter.swift | 1 + .../Reporter/ReportStrictlicity.swift | 2 +- .../LocalizeCheckerCLI.swift | 8 ++++---- .../SourceFileBatchCheckerTests.swift | 2 +- 13 files changed, 35 insertions(+), 30 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index dafe389..2b14e91 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -6,9 +6,9 @@ on: jobs: run-tests: - runs-on: macos-14 + runs-on: macos-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Run tests run: swift test --explicit-target-dependency-import-check error \ No newline at end of file diff --git a/Package.swift b/Package.swift index 6e48c17..e41c650 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,11 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.0 import PackageDescription import Foundation let package = Package( name: "LocalizeChecker", - platforms: [.macOS(.v14)], + platforms: [.macOS(.v15)], products: [ .executable(name: "check-localize", targets: ["LocalizeCheckerCLI"]), .library(name: "LocalizeChecker", targets: ["LocalizeChecker"]) @@ -49,5 +49,6 @@ let package = Package( path: "Tests/LocalizeChecker", resources: [.copy("Fixtures")] ) - ] + ], + swiftLanguageModes: [.v5, .v6] ) diff --git a/Sources/LocalizeChecker/Checker/ErrorMessage.swift b/Sources/LocalizeChecker/Checker/ErrorMessage.swift index 6880444..1af44e9 100644 --- a/Sources/LocalizeChecker/Checker/ErrorMessage.swift +++ b/Sources/LocalizeChecker/Checker/ErrorMessage.swift @@ -2,7 +2,7 @@ import Foundation import SwiftSyntax /// Contains all necessary meta data to locate and describe localization check error -public struct ErrorMessage: Equatable, Codable { +public struct ErrorMessage: Equatable, Codable, Sendable { /// Key of the localized string in the dictionary public let key: String diff --git a/Sources/LocalizeChecker/Checker/SourceFileBatchChecker.swift b/Sources/LocalizeChecker/Checker/SourceFileBatchChecker.swift index 9b0e376..390c5fa 100644 --- a/Sources/LocalizeChecker/Checker/SourceFileBatchChecker.swift +++ b/Sources/LocalizeChecker/Checker/SourceFileBatchChecker.swift @@ -1,7 +1,7 @@ import Foundation /// Performs multiple checks at once considering certain optimizations depending on the amount of them -public final class SourceFileBatchChecker { +public actor SourceFileBatchChecker { public typealias ReportStream = AsyncThrowingStream public typealias UnusedKeysStream = AsyncThrowingStream @@ -63,12 +63,13 @@ public final class SourceFileBatchChecker { @discardableResult func run() throws -> ReportStream { let localizeBundle = try LocalizeBundle(directoryPath: localizeBundleUrl.path) + let chunks = chunks return ReportStream { continuation in Task { await withThrowingTaskGroup(of: ReportMessages.self) { group in for filesChunk in chunks { group.addTask { - try self.processBatch( + try await self.processBatch( ofSourceFiles: Array(filesChunk), in: localizeBundle ) @@ -98,7 +99,7 @@ public final class SourceFileBatchChecker { try await withThrowingTaskGroup(of: ReportMessages.self) { group in for filesChunk in chunks { group.addTask { - try self.processBatch( + try await self.processBatch( ofSourceFiles: Array(filesChunk), in: localizeBundle ) diff --git a/Sources/LocalizeChecker/Checker/SourceFileChecker.swift b/Sources/LocalizeChecker/Checker/SourceFileChecker.swift index cd8dd83..a98a7bc 100644 --- a/Sources/LocalizeChecker/Checker/SourceFileChecker.swift +++ b/Sources/LocalizeChecker/Checker/SourceFileChecker.swift @@ -24,7 +24,7 @@ final class SourceFileChecker { func start() throws { guard try fastCheck() else { return } - let syntaxTree = Parser.parse(source: try String(contentsOf: fileUrl)) + let syntaxTree = Parser.parse(source: try String(contentsOf: fileUrl, encoding: .utf8)) let converter = SourceLocationConverter(fileName: fileUrl.path, tree: syntaxTree) let parser = LocalizeParser(converter: converter) @@ -43,7 +43,7 @@ final class SourceFileChecker { private extension SourceFileChecker { func fastCheck() throws -> Bool { - try String(contentsOf: fileUrl).contains(".\(literalMarker)") + try String(contentsOf: fileUrl, encoding: .utf8).contains(".\(literalMarker)") } } diff --git a/Sources/LocalizeChecker/Checker/UnusedKey.swift b/Sources/LocalizeChecker/Checker/UnusedKey.swift index d3dbc99..b46ecd9 100644 --- a/Sources/LocalizeChecker/Checker/UnusedKey.swift +++ b/Sources/LocalizeChecker/Checker/UnusedKey.swift @@ -1,6 +1,6 @@ import Foundation -public struct UnusedKeyMessage: Equatable, Hashable, Codable { +public struct UnusedKeyMessage: Equatable, Hashable, Codable, Sendable { /// Key of the localized string in the dictionary public let key: String } diff --git a/Sources/LocalizeChecker/Data source/LocalizeBundle.swift b/Sources/LocalizeChecker/Data source/LocalizeBundle.swift index 083a154..1864316 100644 --- a/Sources/LocalizeChecker/Data source/LocalizeBundle.swift +++ b/Sources/LocalizeChecker/Data source/LocalizeBundle.swift @@ -1,4 +1,5 @@ import Foundation +import Synchronization enum Localizable: String { case strings = "Localizable.strings" @@ -7,17 +8,17 @@ enum Localizable: String { /// Represents a merged bundle structure for **Localizable.strings** and **Localizable.stringsdict** /// Each key can be obtained by subscript -public final class LocalizeBundle { - +public final class LocalizeBundle: Sendable { + typealias LocalizeHash = [String : Any] - private let dictionary: LocalizeHash - + private nonisolated(unsafe) let dictionary: LocalizeHash + /// Create bundle from file /// - Parameter fileUrl: URL of the strings file public init(fileUrl: URL) { dictionary = Self.parseStrings(fileUrl: fileUrl) - + print("LocalizeBundle(fileUrl:): dict.count = \(dictionary.keys.count)") } @@ -28,7 +29,7 @@ public final class LocalizeBundle { let fileManager = FileManager() let items = try fileManager.contentsOfDirectory(atPath: directoryPath) - dictionary = try items.reduce(into: [:]) { accDict, item in + let dict: LocalizeHash = try items.reduce(into: [:]) { accDict, item in let fileUrl = directoryUrl.appendingPathComponent(item) switch Localizable(rawValue: item) { case .strings: @@ -44,7 +45,8 @@ public final class LocalizeBundle { break } } - + dictionary = dict + print("LocalizeBundle(directoryPath:): dict.count = \(dictionary.keys.count)") } @@ -63,7 +65,7 @@ public final class LocalizeBundle { private extension LocalizeBundle { static func parseStrings(fileUrl: URL) -> [String: String] { - let rawContent = try? String(contentsOf: fileUrl) + let rawContent = try? String(contentsOf: fileUrl, encoding: .utf8) return rawContent.map(Self.parseStrings) ?? [:] } @@ -98,7 +100,7 @@ private extension LocalizeBundle { extension LocalizeBundle: ExpressibleByStringLiteral { - convenience public init(stringLiteral string: String) { + public convenience init(stringLiteral string: String) { self.init(fileUrl: URL(fileURLWithPath: string)) } diff --git a/Sources/LocalizeChecker/Parser/LocalizeEntry.swift b/Sources/LocalizeChecker/Parser/LocalizeEntry.swift index 6ff4c7a..6363a2f 100644 --- a/Sources/LocalizeChecker/Parser/LocalizeEntry.swift +++ b/Sources/LocalizeChecker/Parser/LocalizeEntry.swift @@ -1,7 +1,7 @@ import Foundation -import SwiftSyntax +@preconcurrency import SwiftSyntax -struct LocalizeEntry: Hashable { +struct LocalizeEntry: Hashable, Sendable { let key: String let sourceLocation: SourceLocation } diff --git a/Sources/LocalizeChecker/Reporter/Formatters/XcodeReportFormatter.swift b/Sources/LocalizeChecker/Reporter/Formatters/XcodeReportFormatter.swift index 35a91e5..66edb2a 100644 --- a/Sources/LocalizeChecker/Reporter/Formatters/XcodeReportFormatter.swift +++ b/Sources/LocalizeChecker/Reporter/Formatters/XcodeReportFormatter.swift @@ -1,7 +1,7 @@ import Foundation /// Formats localization check error to the suitable format for Xcode -public struct XcodeReportFormatter: ReportFormatter { +public struct XcodeReportFormatter: ReportFormatter, Sendable { private let strictlicity: ReportStrictlicity diff --git a/Sources/LocalizeChecker/Reporter/ReportPrinter.swift b/Sources/LocalizeChecker/Reporter/ReportPrinter.swift index 31e2745..57aff1f 100644 --- a/Sources/LocalizeChecker/Reporter/ReportPrinter.swift +++ b/Sources/LocalizeChecker/Reporter/ReportPrinter.swift @@ -1,5 +1,6 @@ import Foundation +@MainActor /// Prints checker reports in the given format public final class ReportPrinter { diff --git a/Sources/LocalizeChecker/Reporter/ReportStrictlicity.swift b/Sources/LocalizeChecker/Reporter/ReportStrictlicity.swift index 091247d..c86a84a 100644 --- a/Sources/LocalizeChecker/Reporter/ReportStrictlicity.swift +++ b/Sources/LocalizeChecker/Reporter/ReportStrictlicity.swift @@ -1,6 +1,6 @@ /// Level of stritclicity used to output reports /// **Available options**: error, warning -public enum ReportStrictlicity: String { +public enum ReportStrictlicity: String, Sendable { case error case warning } diff --git a/Sources/LocalizeCheckerCLI/LocalizeCheckerCLI.swift b/Sources/LocalizeCheckerCLI/LocalizeCheckerCLI.swift index 39de11d..139cf86 100644 --- a/Sources/LocalizeCheckerCLI/LocalizeCheckerCLI.swift +++ b/Sources/LocalizeCheckerCLI/LocalizeCheckerCLI.swift @@ -1,5 +1,5 @@ import Foundation -import ArgumentParser +@preconcurrency import ArgumentParser import LocalizeChecker @main @@ -16,7 +16,7 @@ struct LocalizeCheckerCLI: AsyncParsableCommandProtocol, SourceFilesTraversalTra @Option(help: "Level of panic on invalid keys usage: (error | warning). `error` is default") var strictlicity: ReportStrictlicity? - static var configuration = CommandConfiguration( + static let configuration = CommandConfiguration( commandName: "check-localize", abstract: "Scans for misused localization keys in your project sources", version: "0.1.2" @@ -29,13 +29,13 @@ struct LocalizeCheckerCLI: AsyncParsableCommandProtocol, SourceFilesTraversalTra sourceFiles: try files, localizeBundleFile: localizeBundleFile ) - let reportPrinter = ReportPrinter( + let reportPrinter = await ReportPrinter( formatter: XcodeReportFormatter(strictlicity: strictlicity ?? .error) ) let start = ProcessInfo.processInfo.systemUptime - for try await report in try checker.reports { + for try await report in try await checker.reports { await reportPrinter.print(report) } diff --git a/Tests/LocalizeChecker/SourceFileBatchCheckerTests.swift b/Tests/LocalizeChecker/SourceFileBatchCheckerTests.swift index 7e2a3ce..e81c7c4 100644 --- a/Tests/LocalizeChecker/SourceFileBatchCheckerTests.swift +++ b/Tests/LocalizeChecker/SourceFileBatchCheckerTests.swift @@ -71,7 +71,7 @@ extension SourceFileBatchCheckerTests { // Then XCTAssertEqual(processedFilenames.sorted(), fileNames.sorted()) - XCTAssertLessThan(end - start, 1.2) + XCTAssertLessThan(end - start, 3.0) } func testIfHalfWrongFilesProducedErrors() async throws { From a853daf2ed60a04a5e8c6891e78ac1e46cec236b Mon Sep 17 00:00:00 2001 From: Konstantin Yurichev Date: Thu, 18 Dec 2025 19:58:31 +0100 Subject: [PATCH 4/4] Fix warnings --- Tests/LocalizeChecker/LocalizeParserTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/LocalizeChecker/LocalizeParserTests.swift b/Tests/LocalizeChecker/LocalizeParserTests.swift index ae275bb..ae72ffb 100644 --- a/Tests/LocalizeChecker/LocalizeParserTests.swift +++ b/Tests/LocalizeChecker/LocalizeParserTests.swift @@ -36,7 +36,7 @@ extension LocalizeParserTests { func testFoundLocalizedString() throws { // GIVEN setup(input: inputSource) - let parsed = Parser.parse(source: try String(contentsOf: fileUrl)) + let parsed = Parser.parse(source: try String(contentsOf: fileUrl, encoding: .utf8)) let converter = SourceLocationConverter(fileName: fileUrl.path, tree: parsed) let checker = LocalizeParser(converter: converter) @@ -50,7 +50,7 @@ extension LocalizeParserTests { func testUsualStringLiteralNotTreatedAsLocalizedString() throws { // GIVEN setup(input: inputSource1) - let parsed = Parser.parse(source: try String(contentsOf: fileUrl)) + let parsed = Parser.parse(source: try String(contentsOf: fileUrl, encoding: .utf8)) let converter = SourceLocationConverter(fileName: fileUrl.path, tree: parsed) let checker = LocalizeParser(converter: converter)