├── .gitignore ├── LICENSE ├── Package.swift ├── README.md └── Sources └── LocalizableChecker └── LocalizableChecker.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | Packages/ 41 | Package.pins 42 | Package.resolved 43 | *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | # Others 93 | .DS_Store 94 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jonathan Gander 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "LocalizableChecker", 8 | dependencies: [ 9 | // Dependencies declare other packages that this package depends on. 10 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.0"), 11 | ], 12 | targets: [ 13 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 14 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 15 | .executableTarget( 16 | name: "LocalizableChecker", 17 | dependencies: [.product(name: "ArgumentParser", package: "swift-argument-parser")]), 18 | ] 19 | ) 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LocalizableChecker 2 | A Swift CLI to check if keys from a Localizable.strings file are unused in your project. 3 | 4 | When you're building a translated app, you create a lot of translation keys and values. Sometimes you remove some code but you forget to remove matched translation keys. Your translation file is getting longer and you lose time to translate keys that are not yet used in your app. 5 | 6 | This tool is for you! It will print every key from a `Localizable.strings` file (or any `.strings` file) that are not used in your app. 7 | 8 | And, it can also log if a key has an empty value, such as `"mv.help.text" = "";`. 9 | 10 | ## Usage 11 | 12 | ### Installation 13 | ``` 14 | $ git clone https://github.com/Jonathan-Gander/LocalizableChecker 15 | $ cd LocalizableChecker 16 | ``` 17 | 18 | ### Arguments and options 19 | 20 | There are 3 mandatory arguments: 21 | 22 | - `source-file-path`: The path to your `Localizable.strings` file where are the keys to check (including filename and its extension). 23 | - `project-path`: The path to your project or directory in which each key will be check. Note that your `Localizable.strings` file can be in this directory also. 24 | - `allow-nb-times`: Number of times each key will be found at least. For example, if you search in all files and your project directory contains two `Localizable.strings` files (one for each language), this value should be 2. Because you are sure all keys will be found at least two times. That means, if a key is found two times (or less), it is unused in your project because it only appears in your two `Localizable.strings` files. 25 | If you have set `extensions` option (see below) to only search in Swift files for example, you can set this argument to 0. 26 | 27 | And 3 options: 28 | 29 | - `--extensions` or `--allowed-files-extensions`: You can choose to only search in files with specific extensions. For example, if you want to check only in Swift files, you can set this option to `swift` (do not add the dot). If you want to specify many extensions, write them spearated by a comma: `swift,m`. 30 | Setting specific extensions will make faster search. 31 | - `--log-empty-values`: Add this option to also log if a key has an empty value. For example `"mv.help.text" = "";` would log the key because its value is an empty string. 32 | - `--anxious-mode`: Add this option to print each time a key is found in project. It will add more log and reduce your anxiety of seeing nothing printed. ;) 33 | 34 | ### Run 35 | 36 | Examples: 37 | 38 | ``` 39 | # Search in all files. And have probably 2 Localizable.strings files. 40 | $ swift run LocalizableChecker "/Users/user/Projects/myproject/myproject/Resources/en.lproj/Localizable.strings" "/Users/user/Projects/myproject" 2 41 | 42 | # Search in files with .swift extensions only. 43 | $ swift run LocalizableChecker "/Users/user/Projects/myproject/myproject/Resources/en.lproj/Localizable.strings" "/Users/user/Projects/myproject" 0 --extensions swift 44 | 45 | # Search in files with .swift extensions only. Also log empty values. 46 | $ swift run LocalizableChecker "/Users/user/Projects/myproject/myproject/Resources/en.lproj/Localizable.strings" "/Users/user/Projects/myproject" 0 --extensions swift --log-empty-values 47 | ``` 48 | 49 | ### Output 50 | 51 | Typical output log: 52 | 53 | ``` 54 | 👋 Welcome in LocalizableChecker 55 | This tool will check if keys from a Localizable.strings file are unused in your project. 56 | Created by Jonathan Gander 57 | -------------------------------------------------------- 58 | 59 | Will check keys from file... 60 | /Users/user/Projects/myproject/myproject/Resources/en.lproj/Localizable.strings 61 | in files with extension swift from directory... 62 | /Users/user/Projects/myproject 63 | 64 | ℹ️ Empty values will be logged. 65 | 66 | 🚀 running ... 67 | (It may take quite long! If you see nothing and it makes you anxious, try setting anxiousMode to true.) 68 | 69 | 🛑 key '"mpv.position"' is unused (found 0 time). 70 | ⚠️ warning, key '"mv.help.text"' has an empty value. 71 | 🛑 key '"wv.title"' is unused (found 0 time). 72 | 73 | 🎉 finished! 74 | ``` 75 | 76 | ## Licence 77 | 78 | Be free to use my `LocalizableChecker`. Licence is available [here](https://github.com/Jonathan-Gander/LocalizableChecker/blob/main/LICENSE). 79 | -------------------------------------------------------------------------------- /Sources/LocalizableChecker/LocalizableChecker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalizableChecker.swift 3 | // 4 | // Created by Jonathan Gander on 14.11.22. 5 | // 6 | import Foundation 7 | import ArgumentParser 8 | 9 | @main 10 | struct LocalizableChecker: ParsableCommand { 11 | 12 | // MARK: - Arguments 13 | @Argument(help: "Path to file where are the keys to check (including filename and its extension).") 14 | var sourceFilePath: String 15 | 16 | @Argument(help: "Path to your project or directory in which each key will be check.") 17 | var projectPath: String 18 | 19 | @Argument(help: "Number of times each key will be found at least. For example, if you search in all files and your project directory contains two Localizable.strings files (one for each language), this value should be 2. Because you are sure all keys will be found at least two times. That means, if a key is found two times (or less), it is unused in your project because it only appears in your two Localizable.strings files. If you set 'extensions' option to only search in .swift files for example, you can set this argument to 0.") 20 | var allowNbTimes: Int 21 | 22 | // MARK: - Options 23 | @Option(name: [.customLong("extensions"), .long], 24 | help: "Set extensions of files in which you want to search for keys. If you don't set this parameter, it will search in all files. Do not add a dot, only extensions, spearated by a comma.", 25 | transform: { str in 26 | if str.contains(",") { 27 | return str.split(separator: ",").map({ String($0) }) 28 | } 29 | else { 30 | return [str] 31 | } 32 | }) 33 | var allowedFilesExtensions: [String] = [] 34 | 35 | @Flag(help: "Add this option to also check if a key has an empty value. For example, this line would be logged: \"mv.help.text\" = \"\";") 36 | var logEmptyValues: Bool = false 37 | 38 | @Flag(help: "Add this option to print each time a key is found in project. It will add more log and reduce your anxiety of seeing nothing printed. ;)") 39 | var anxiousMode: Bool = false 40 | 41 | // MARK: - Main 42 | func run() { 43 | 44 | print("👋 Welcome in LocalizableChecker") 45 | print("This tool will check if keys from a Localizable.strings file are unused in your project.") 46 | print("Created by Jonathan Gander") 47 | print("--------------------------------------------------------\n") 48 | 49 | print("Will check keys from file...\n\t\(sourceFilePath)") 50 | printMessageExtensions() 51 | 52 | if logEmptyValues { 53 | print("ℹ️ Empty values will be logged.\n") 54 | } 55 | 56 | if anxiousMode { 57 | print("ℹ️ Anxious mode is enabled. It will print a lot of text. Set anxiousMode variable to false to only log unused keys.\n") 58 | } 59 | 60 | print("🚀 running ...\n(It may take quite long! If you see nothing and it makes you anxious, enable anxious mode option.)\n") 61 | 62 | // Check input file and directory 63 | if !FileManager.default.fileExists(atPath: sourceFilePath) { 64 | print("⛔️ File \(sourceFilePath) does not exist. Could not start tool.") 65 | return 66 | } 67 | 68 | if !FileManager.default.fileExists(atPath: projectPath) { 69 | print("⛔️ Directory \(projectPath) does not exist. Could not start tool.") 70 | return 71 | } 72 | 73 | // Run 74 | foreachLine(inFile: sourceFilePath, apply: { line in 75 | checkUnusedKey(fromLine: line, inFilesInDirectory: projectPath, withExtensions: allowedFilesExtensions, expectedMinimalNbTimes: allowNbTimes) 76 | }) 77 | 78 | print("\n🎉 finished!") 79 | } 80 | 81 | // MARK: - 82 | /// Check if current line is used in all files from directory. 83 | /// - Parameters: 84 | /// - line: line to check 85 | /// - directory: root directory where to check files 86 | /// - withExtensions: if set will only search in files with those extensions. If array is empty, search in all files. 87 | /// - expectedMinimalNbTimes: number of times the key is at least and can be considered like unused if less or equal to this value 88 | private func checkUnusedKey(fromLine line: String, inFilesInDirectory directory: String, withExtensions: [String], expectedMinimalNbTimes: Int) { 89 | 90 | guard let key = getKey(line) else { return } 91 | 92 | var nbFound = 0 93 | foreachFile(inDirectory: directory, withExtensions: withExtensions, recursive: true, apply: { filePath in 94 | foreachLine(inFile: filePath, apply: { line in 95 | if line.contains(key) { 96 | nbFound += 1 97 | } 98 | }) 99 | }) 100 | 101 | if nbFound <= expectedMinimalNbTimes { 102 | print("🛑 key '\(key)' is unused (found \(nbFound) \(nbFound > 1 ? "times" : "time")).") 103 | } 104 | else if anxiousMode { 105 | print("✅ key '\(key)' is used \(nbFound) \(nbFound > 1 ? "times" : "time").") 106 | } 107 | } 108 | 109 | 110 | /// Browse each line of a file and apply a function on each 111 | /// - Parameters: 112 | /// - filePath: file path 113 | /// - apply: function to apply to each line. Takes a line as parameter. 114 | private func foreachLine(inFile filePath: String, apply: (String) -> Void) { 115 | guard let contents = try? String(contentsOfFile: filePath) else { return } 116 | let lines = contents.split(separator:"\n") 117 | for line in lines { 118 | apply(String(line)) 119 | } 120 | } 121 | 122 | /// - Parameters: 123 | /// - directory: root directory 124 | /// - allowedExtensions: if set will only browse files with those extensions. If array is empty, browse all files. 125 | /// - recursive: set to true to browse subdirectories 126 | /// - apply: function to apply to each file. Takes file path as parameters 127 | private func foreachFile(inDirectory directory: String, withExtensions allowedExtensions: [String], recursive: Bool = false, apply: (String) -> Void) { 128 | 129 | let fileManager = FileManager.default 130 | 131 | guard let directoryContent = try? fileManager.contentsOfDirectory(atPath: directory) else { 132 | fatalError("Could not open directory \(directory).") 133 | } 134 | 135 | for item in directoryContent { 136 | let itemURL = URL(fileURLWithPath: directory).appendingPathComponent(item) 137 | if isDirectory(itemURL) { 138 | 139 | if recursive { 140 | foreachFile(inDirectory: itemURL.path, withExtensions: allowedExtensions, recursive: recursive, apply: apply) 141 | } 142 | } 143 | else { 144 | if allowedExtensions.count == 0 || allowedExtensions.contains(itemURL.pathExtension.lowercased()) { 145 | apply(itemURL.path) 146 | } 147 | } 148 | } 149 | } 150 | 151 | // MARK: - Helper functions 152 | 153 | /// Check if URL is a directory 154 | /// - Parameter url: url to check 155 | /// - Returns: true if directory 156 | private func isDirectory(_ url: URL) -> Bool { 157 | var isDirectory: ObjCBool = false 158 | return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue 159 | } 160 | 161 | 162 | /// Check if a line is a key and value line 163 | /// Example: "key" = "value"; 164 | /// - Parameter line: line to check 165 | /// - Returns: true if line is a key and value 166 | private func isKeyValueLine(_ line: String) -> Bool { 167 | let line = line.trimmingCharacters(in: .whitespacesAndNewlines) 168 | 169 | // Starts with " and finish with ; 170 | guard line.hasPrefix("\"") && line.hasSuffix(";") else { return false } 171 | 172 | // And contains exaclty one = 173 | guard line.split(separator: "=").count == 2 else { return false } 174 | 175 | return true 176 | } 177 | 178 | 179 | /// Returns key from a line. nil if line is not a key and value line. 180 | /// - Parameter line: line to get key from 181 | /// - Returns: key or nil 182 | private func getKey(_ line: String) -> String? { 183 | guard isKeyValueLine(line) else { return nil } 184 | 185 | let components = line.split(separator: "=") 186 | guard components.count == 2 else { return nil } 187 | 188 | var key = String(components.first!) 189 | key = key.trimmingCharacters(in: .whitespacesAndNewlines) 190 | 191 | guard key.hasPrefix("\"") && key.hasSuffix("\"") else { return nil } 192 | 193 | if logEmptyValues { 194 | var value = components[1].trimmingCharacters(in: .whitespacesAndNewlines) 195 | if value.hasSuffix(";") { 196 | value.removeLast() 197 | value = value.trimmingCharacters(in: .whitespacesAndNewlines) 198 | 199 | if value == "\"\"" { 200 | print("⚠️ warning, key '\(key)' has an empty value.") 201 | } 202 | } 203 | } 204 | 205 | return key 206 | } 207 | 208 | // MARK: - 209 | private func printMessageExtensions() { 210 | var str = "in" 211 | 212 | if allowedFilesExtensions.count == 0 { 213 | str += " all files" 214 | } 215 | else if allowedFilesExtensions.count == 1 { 216 | str += " files with extension \(allowedFilesExtensions.first!)" 217 | } 218 | else if allowedFilesExtensions.count > 1 { 219 | str += " files with extensions \(allowedFilesExtensions.joined(separator: ", "))" 220 | } 221 | 222 | str += " from directory...\n\t\(projectPath)\n" 223 | print(str) 224 | } 225 | } 226 | --------------------------------------------------------------------------------