Skip to content

Commit 4fe2bce

Browse files
authored
Find unused keys (#3)
* Find unused keys * Fix access level imports * Support Swift 6 * Fix warnings
1 parent 011e810 commit 4fe2bce

14 files changed

Lines changed: 155 additions & 43 deletions

File tree

.github/workflows/swift.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ on:
66

77
jobs:
88
run-tests:
9-
runs-on: macos-14
9+
runs-on: macos-latest
1010

1111
steps:
12-
- uses: actions/checkout@v3
12+
- uses: actions/checkout@v6
1313
- name: Run tests
1414
run: swift test --explicit-target-dependency-import-check error

Package.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
// swift-tools-version: 5.9
1+
// swift-tools-version: 6.0
22

33
import PackageDescription
44
import Foundation
55

66
let package = Package(
77
name: "LocalizeChecker",
8-
platforms: [.macOS(.v14)],
8+
platforms: [.macOS(.v15)],
99
products: [
1010
.executable(name: "check-localize", targets: ["LocalizeCheckerCLI"]),
1111
.library(name: "LocalizeChecker", targets: ["LocalizeChecker"])
@@ -28,6 +28,9 @@ let package = Package(
2828
.product(name: "SwiftSyntax", package: "swift-syntax"),
2929
.product(name: "SwiftParser", package: "swift-syntax"),
3030
"LocalizeChecker",
31+
],
32+
swiftSettings: [
33+
.enableUpcomingFeature("AccessLevelOnImport")
3134
]
3235
),
3336
.target(
@@ -46,5 +49,6 @@ let package = Package(
4649
path: "Tests/LocalizeChecker",
4750
resources: [.copy("Fixtures")]
4851
)
49-
]
52+
],
53+
swiftLanguageModes: [.v5, .v6]
5054
)

Sources/LocalizeChecker/Checker/ErrorMessage.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22
import SwiftSyntax
33

44
/// Contains all necessary meta data to locate and describe localization check error
5-
public struct ErrorMessage: Equatable, Codable {
5+
public struct ErrorMessage: Equatable, Codable, Sendable {
66
/// Key of the localized string in the dictionary
77
public let key: String
88

Sources/LocalizeChecker/Checker/SourceFileBatchChecker.swift

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
import Foundation
22

33
/// Performs multiple checks at once considering certain optimizations depending on the amount of them
4-
public final class SourceFileBatchChecker {
4+
public actor SourceFileBatchChecker {
55

66
public typealias ReportStream = AsyncThrowingStream<ErrorMessage, Error>
7-
7+
public typealias UnusedKeysStream = AsyncThrowingStream<UnusedKeyMessage, Error>
8+
typealias ReportMessages = (errors: [ErrorMessage], unused: [UnusedKeyMessage], used: [LocalizeEntry])
9+
810
@available(macOS 12, *)
911
/// Async stream of obtained check reports
1012
public var reports: ReportStream {
1113
get throws {
1214
try run()
1315
}
1416
}
15-
16-
@available(macOS, deprecated: 12, obsoleted: 13, message: "Use much faster reports stream")
17-
public func getReports() throws -> [ErrorMessage] {
18-
try syncRun()
17+
18+
public var unusedKeys: [UnusedKeyMessage] {
19+
get async throws {
20+
try await runForUnusedKeys()
21+
}
1922
}
2023

2124
@available(macOS 12, *)
@@ -60,20 +63,21 @@ public final class SourceFileBatchChecker {
6063
@discardableResult
6164
func run() throws -> ReportStream {
6265
let localizeBundle = try LocalizeBundle(directoryPath: localizeBundleUrl.path)
66+
let chunks = chunks
6367
return ReportStream { continuation in
6468
Task {
65-
await withThrowingTaskGroup(of: [ErrorMessage].self) { group in
69+
await withThrowingTaskGroup(of: ReportMessages.self) { group in
6670
for filesChunk in chunks {
6771
group.addTask {
68-
try self.processBatch(
72+
try await self.processBatch(
6973
ofSourceFiles: Array(filesChunk),
7074
in: localizeBundle
7175
)
7276
}
7377
}
7478

7579
do {
76-
for try await reportsChunk in group {
80+
for try await (reportsChunk, _, _) in group {
7781
reportsChunk.forEach {
7882
continuation.yield($0)
7983
}
@@ -86,9 +90,40 @@ public final class SourceFileBatchChecker {
8690
}
8791
}
8892
}
89-
93+
94+
@available(macOS 12, *)
95+
@discardableResult
96+
func runForUnusedKeys() async throws -> [UnusedKeyMessage] {
97+
let localizeBundle = try LocalizeBundle(directoryPath: localizeBundleUrl.path)
98+
return try await Task {
99+
try await withThrowingTaskGroup(of: ReportMessages.self) { group in
100+
for filesChunk in chunks {
101+
group.addTask {
102+
try await self.processBatch(
103+
ofSourceFiles: Array(filesChunk),
104+
in: localizeBundle
105+
)
106+
}
107+
}
108+
109+
var usedKeys: Set<String> = []
110+
var unusedKeys: Set<String> = []
111+
for try await (_, unusedKeysChunk, usedKeysChunk) in group {
112+
unusedKeysChunk.forEach {
113+
unusedKeys.insert($0.key)
114+
}
115+
usedKeysChunk.forEach {
116+
usedKeys.insert($0.key)
117+
}
118+
}
119+
let trulyUnusedKeys = unusedKeys.subtracting(usedKeys)
120+
return Array(trulyUnusedKeys.map(UnusedKeyMessage.init(key:)))
121+
}
122+
}.value
123+
}
124+
90125
@discardableResult
91-
func syncRun() throws -> [ErrorMessage] {
126+
func syncRun() throws -> ReportMessages {
92127
let localizeBundle = LocalizeBundle(fileUrl: localizeBundleUrl)
93128
let reports = try self.processBatch(
94129
ofSourceFiles: sourceFiles,
@@ -98,7 +133,7 @@ public final class SourceFileBatchChecker {
98133
return reports
99134
}
100135

101-
private func processBatch(ofSourceFiles files: [String], in localizeBundle: LocalizeBundle) throws -> [ErrorMessage] {
136+
private func processBatch(ofSourceFiles files: [String], in localizeBundle: LocalizeBundle) throws -> ReportMessages {
102137
let fileUrls = files.compactMap(URL.init(fileURLWithPath:))
103138
let sourceCheckers = try fileUrls.map {
104139
try SourceFileChecker(fileUrl: $0, localizeBundle: localizeBundle)
@@ -107,7 +142,11 @@ public final class SourceFileBatchChecker {
107142
try sourceChecker.start()
108143
}
109144

110-
return sourceCheckers.flatMap(\.errors)
145+
return (
146+
sourceCheckers.flatMap(\.errors),
147+
sourceCheckers.flatMap(\.unusedKeys).map(UnusedKeyMessage.init(key:)),
148+
sourceCheckers.flatMap(\.usedKeys)
149+
)
111150
}
112151

113152
}

Sources/LocalizeChecker/Checker/SourceFileChecker.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import SwiftParser
55
final class SourceFileChecker {
66

77
var errors: [ErrorMessage] = []
8+
var usedKeys: [LocalizeEntry] = []
9+
var unusedKeys: [String] = []
810

911
private let fileUrl: URL
1012
private let bundle: LocalizeBundle
@@ -22,22 +24,26 @@ final class SourceFileChecker {
2224
func start() throws {
2325
guard try fastCheck() else { return }
2426

25-
let syntaxTree = Parser.parse(source: try String(contentsOf: fileUrl))
27+
let syntaxTree = Parser.parse(source: try String(contentsOf: fileUrl, encoding: .utf8))
2628
let converter = SourceLocationConverter(fileName: fileUrl.path, tree: syntaxTree)
2729
let parser = LocalizeParser(converter: converter)
2830

2931
parser.walk(syntaxTree)
3032
errors = parser.foundKeys
3133
.filter(notExistsInBundle)
3234
.compactMap(\.errorMessage)
35+
usedKeys = parser.foundKeys
36+
unusedKeys = bundle.keys.filter { key in
37+
!parser.foundKeys.contains(where: { $0.key == key })
38+
}
3339
}
3440

3541
}
3642

3743
private extension SourceFileChecker {
3844

3945
func fastCheck() throws -> Bool {
40-
try String(contentsOf: fileUrl).contains(".\(literalMarker)")
46+
try String(contentsOf: fileUrl, encoding: .utf8).contains(".\(literalMarker)")
4147
}
4248

4349
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import Foundation
2+
3+
public struct UnusedKeyMessage: Equatable, Hashable, Codable, Sendable {
4+
/// Key of the localized string in the dictionary
5+
public let key: String
6+
}

Sources/LocalizeChecker/Data source/LocalizeBundle.swift

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import Synchronization
23

34
enum Localizable: String {
45
case strings = "Localizable.strings"
@@ -7,17 +8,17 @@ enum Localizable: String {
78

89
/// Represents a merged bundle structure for **Localizable.strings** and **Localizable.stringsdict**
910
/// Each key can be obtained by subscript
10-
public final class LocalizeBundle {
11-
11+
public final class LocalizeBundle: Sendable {
12+
1213
typealias LocalizeHash = [String : Any]
1314

14-
private let dictionary: LocalizeHash
15-
15+
private nonisolated(unsafe) let dictionary: LocalizeHash
16+
1617
/// Create bundle from file
1718
/// - Parameter fileUrl: URL of the strings file
1819
public init(fileUrl: URL) {
1920
dictionary = Self.parseStrings(fileUrl: fileUrl)
20-
21+
2122
print("LocalizeBundle(fileUrl:): dict.count = \(dictionary.keys.count)")
2223
}
2324

@@ -28,7 +29,7 @@ public final class LocalizeBundle {
2829
let fileManager = FileManager()
2930
let items = try fileManager.contentsOfDirectory(atPath: directoryPath)
3031

31-
dictionary = try items.reduce(into: [:]) { accDict, item in
32+
let dict: LocalizeHash = try items.reduce(into: [:]) { accDict, item in
3233
let fileUrl = directoryUrl.appendingPathComponent(item)
3334
switch Localizable(rawValue: item) {
3435
case .strings:
@@ -44,22 +45,27 @@ public final class LocalizeBundle {
4445
break
4546
}
4647
}
47-
48+
dictionary = dict
49+
4850
print("LocalizeBundle(directoryPath:): dict.count = \(dictionary.keys.count)")
4951
}
5052

5153
public subscript(key: String) -> Any? {
5254
dictionary[key]
5355
}
54-
56+
57+
public var keys: [String] {
58+
Array(dictionary.keys)
59+
}
60+
5561
}
5662

5763
// MARK:- Parsing
5864

5965
private extension LocalizeBundle {
6066

6167
static func parseStrings(fileUrl: URL) -> [String: String] {
62-
let rawContent = try? String(contentsOf: fileUrl)
68+
let rawContent = try? String(contentsOf: fileUrl, encoding: .utf8)
6369
return rawContent.map(Self.parseStrings) ?? [:]
6470
}
6571

@@ -94,7 +100,7 @@ private extension LocalizeBundle {
94100

95101
extension LocalizeBundle: ExpressibleByStringLiteral {
96102

97-
convenience public init(stringLiteral string: String) {
103+
public convenience init(stringLiteral string: String) {
98104
self.init(fileUrl: URL(fileURLWithPath: string))
99105
}
100106

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Foundation
2-
import SwiftSyntax
2+
@preconcurrency import SwiftSyntax
33

4-
struct LocalizeEntry {
4+
struct LocalizeEntry: Hashable, Sendable {
55
let key: String
66
let sourceLocation: SourceLocation
77
}

Sources/LocalizeChecker/Reporter/Formatters/XcodeReportFormatter.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Foundation
22

33
/// Formats localization check error to the suitable format for Xcode
4-
public struct XcodeReportFormatter: ReportFormatter {
4+
public struct XcodeReportFormatter: ReportFormatter, Sendable {
55

66
private let strictlicity: ReportStrictlicity
77

Sources/LocalizeChecker/Reporter/ReportPrinter.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22

3+
@MainActor
34
/// Prints checker reports in the given format
45
public final class ReportPrinter {
56

0 commit comments

Comments
 (0)