├── .gitignore ├── LICENSE ├── Makefile ├── Package.swift ├── README.md └── Sources └── main.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | needless 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Daniel Duan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: build 2 | mv .build/release/needless /usr/local/bin 3 | 4 | build: clean 5 | swift build -c release 6 | 7 | clean: 8 | rm -rf .build 9 | 10 | uninstall: 11 | rm -f /usr/local/bin/needless 12 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | import PackageDescription 2 | 3 | let package = Package( 4 | name: "needless" 5 | ) 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Omit Needless Words # 2 | 3 | To promote clear usage, the Swift API Design Guidelines advice that we [omit 4 | needless words][omit needless words] in function names. Words that *merely 5 | repeat* type information are specifically identified as needless. 6 | 7 | This is a tool that helps you spot those words in your code base. 8 | 9 | ## Install ## 10 | 11 | Prerequisite: have Swift 3 installed on your system. 12 | 13 | 1. Clone or download content of this repository. 14 | 2. run `make`. 15 | 16 | ## Usage ## 17 | 18 | ### Basic ### 19 | 20 | The command `needless` can process text from STDIN or files specified in a list of 21 | paths. The simplest way to use it is one of the following: 22 | 23 | ``` 24 | needless path/to/file1.swift path/to/file2.swift 25 | ``` 26 | ``` 27 | echo "func someName(foo bar: Baz..." | needless 28 | ``` 29 | 30 | `needless` will print out function names with needless words and suggest an 31 | alternative. 32 | 33 | Run `needless -h` for more details, or read the next section. 34 | 35 | ### Options ### 36 | 37 | Several output formats are included for different use scenarios. They make this 38 | command more useful when combined with other scripts/tools. 39 | 40 | * By default, `needless` prints output in a readable format: 41 | 42 | ``` 43 | potential needless words in first parameter label in path/to/file.swift (line 87) 44 | private func buttonTitleColor(forType type: NewsfeedItemType) -> UIColor { 45 | ^ 46 | possible alternative: func buttonTitleColor(for type: NewsfeedItemType … 47 | ``` 48 | 49 | * Use the option `-Xcode` for `clang`/`swiftc` style warning: 50 | 51 | ``` 52 | needless -Xcode path/to/file.swift 53 | ``` 54 | ``` 55 | path/to/file.swift:23:5: warning: potential needless words in function name 'func testWithData(_ data: Data …'; perhaps use 'func test(with data: Data …' instead? 56 | ``` 57 | 58 | This means you can add `needless` as a build phase in Xcode to get inline 59 | highlighting. 60 | 61 | ![needless in Xcode](https://cloud.githubusercontent.com/assets/75067/19623971/d2e30a82-9896-11e6-899d-4b899f9e66d2.png) 62 | 63 | 1. add a "Run Script" build phase in your Xcode project and paste in the 64 | following: 65 | 66 | ``` 67 | needless -dollar path/to/file.swift 68 | ``` 69 | ``` 70 | IFS=$'\n' find . -name "*.swift" -exec needless -Xcode {} \; 71 | ``` 72 | 73 | (customize the command according to your needs. e.g. you may want to 74 | change the path `.` to `Sources` to avoid warnings for files in `Packages` 75 | or `Pods` folder). 76 | 2. build your Xcode project. 77 | 78 | * `-dollar` prints results in `$`-separated strings: 79 | 80 | ``` 81 | needless -dollar path/to/file.swift 82 | ``` 83 | ``` 84 | potential needless words in function name$path/to/file.swift$22$4$func testWithData(_ data: Data$func test(with data: Data 85 | ``` 86 | 87 | That's `[description]$[path]$[line number]$[column number]$[original name]$[suggested name]` 88 | 89 | This format is convenient for parsing and further actions. It's trivial to 90 | read it and do automated replacement, for example. 91 | 92 | * `-diff` will make `needless` only process lines that begin with character 93 | `+`, `!` or `>`. This is handy when you are dealing with patch formats. Make 94 | `needless` part of your [git hooks][git hooks]! 95 | 96 | `-diff` can be combined with `-dollar` and `-Xcode`. 97 | 98 | ## Not a robot ## 99 | 100 | The API guideline starts with "every word in a name should convey salient 101 | information at the use site". `needless` isn't AI with advanced natural 102 | language processing capabilities (yet?). In fact, it assumes you use camelCase 103 | in function names and merely tries to find problematic function names in a very 104 | mechanical (dumb) way. Its suggestions are often awkward/too aggressive. Always 105 | prioritize good human judgement please :) 106 | 107 | [omit needless words]: https://swift.org/documentation/api-design-guidelines/#omit-needless-words 108 | [git hooks]: https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks 109 | -------------------------------------------------------------------------------- /Sources/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct FunctionHead { 4 | let original: String 5 | let line: Int 6 | let pos: Int 7 | var name: String 8 | var firstLabel: String? 9 | let firstParam: String 10 | let firstType: String 11 | 12 | init(_ original: String, _ line: Int, _ pos: Int, _ name: String, 13 | _ label: String?, _ param: String, _ type: String) 14 | { 15 | self.original = original 16 | self.line = line 17 | self.pos = pos 18 | self.name = name 19 | self.firstLabel = label == "_" ? nil : label 20 | self.firstParam = param 21 | self.firstType = type 22 | } 23 | 24 | init(_ other: FunctionHead) { 25 | self.original = other.original 26 | self.line = other.line 27 | self.pos = other.pos 28 | self.name = other.name 29 | self.firstLabel = other.firstLabel 30 | self.firstParam = other.firstParam 31 | self.firstType = other.firstType 32 | } 33 | } 34 | 35 | extension FunctionHead: CustomStringConvertible { 36 | var description: String { 37 | return "func \(name)(\(firstLabel ?? "_") \(firstParam): \(firstType)" 38 | } 39 | } 40 | 41 | extension String { 42 | subscript(_ range: NSRange) -> String { 43 | let s = self.unicodeScalars 44 | let start = s.index(s.startIndex, offsetBy: range.location) 45 | let end = s.index(s.startIndex, offsetBy: range.location + range.length) 46 | return String(s[start.. [String] { 50 | var result = [String]() 51 | var word = [UnicodeScalar]() 52 | for c in self.unicodeScalars { 53 | if c >= "a" && c <= "z" { 54 | word.append(c) 55 | } else { 56 | result.append(String(String.UnicodeScalarView(word))) 57 | word = [c] 58 | } 59 | } 60 | result.append(String(String.UnicodeScalarView(word))) 61 | return result 62 | } 63 | } 64 | 65 | typealias Suggestion = (old: FunctionHead, new: FunctionHead, reason: String) 66 | typealias Suggester = (FunctionHead) -> Suggestion? 67 | 68 | func commonSuffix(_ a: [String], _ b: [String]) -> [String] { 69 | var commonParts = [String]() 70 | for (x, y) in zip(a.reversed(), b.reversed()) { 71 | if x.lowercased() == y.lowercased() { 72 | commonParts.append(x) 73 | } 74 | } 75 | return Array(commonParts.reversed()) 76 | } 77 | 78 | func needlessWordsInFirstLabel(head: FunctionHead) -> Suggestion? { 79 | if let firstLabel = head.firstLabel, 80 | let last = firstLabel.splitByCamelCase().last, 81 | head.firstParam.lowercased().hasSuffix(last.lowercased()), 82 | head.firstType.lowercased().hasSuffix(last.lowercased()), 83 | head.name != head.firstParam 84 | { 85 | let labelParts = firstLabel.splitByCamelCase() 86 | let typeParts = head.firstType.splitByCamelCase() 87 | let commonParts = commonSuffix(labelParts, typeParts) 88 | let p = labelParts.count - commonParts.count 89 | let newLabel = labelParts.prefix(upTo: p).joined(separator: "") 90 | var new = FunctionHead(head) 91 | new.firstLabel = newLabel 92 | let text = "potential needless words in first parameter label" 93 | return (old: head, new: new, text) 94 | } 95 | return nil 96 | } 97 | 98 | let prepositions = Set([ 99 | "aboard", "about", "above", "across", "after", "against", "along", "amid", 100 | "among", "anti", "around", "as", "at", "before", "behind", "below", "beneath", 101 | "beside", "besides", "between", "beyond", "but", "by", "concerning", 102 | "considering", "despite", "down", "during", "except", "excepting", "excluding", 103 | "following", "for", "from", "in", "inside", "into", "like", "minus", "near", 104 | "of", "off", "on", "onto", "opposite", "outside", "over", "past", "per", "plus", 105 | "regarding", "round", "save", "since", "than", "through", "to", "toward", 106 | "towards", "under", "underneath", "unlike", "until", "up", "upon", "versus", 107 | "via", "with", "within", "without", 108 | ]) 109 | 110 | func needlessWordsInName(head: FunctionHead) -> Suggestion? { 111 | let typeParts = head.firstType.splitByCamelCase() 112 | let nameParts = head.name.splitByCamelCase() 113 | let commonParts = commonSuffix(typeParts, nameParts) 114 | if head.firstLabel == nil && 115 | !commonParts.isEmpty && 116 | head.name != head.firstParam && 117 | nameParts.count > commonParts.count 118 | { 119 | let p = nameParts.count - commonParts.count - 1 120 | let preposition = nameParts[p].lowercased() 121 | if prepositions.contains(preposition) { 122 | var new = FunctionHead(head) 123 | new.name = nameParts.prefix(upTo: p).joined(separator: "") 124 | new.firstLabel = preposition.lowercased() 125 | return (old: head, new: new, "potential needless words in function name") 126 | } 127 | } 128 | return nil 129 | } 130 | 131 | func suggest(_ head: FunctionHead, rules: [Suggester]) -> [Suggestion] { 132 | return rules.flatMap { suggester in 133 | return suggester(head) 134 | } 135 | } 136 | 137 | 138 | let headString = "\\bfunc[ ]+(\\w+)\\(([a-z1-9A-Z_]+)?[ ]?(\\w+)[ ]*:[ ]*(\\w+)" 139 | let pattern = try NSRegularExpression(pattern: headString, options: []) 140 | 141 | func head(from line: String, lineNumber: Int) -> FunctionHead? { 142 | let range = NSMakeRange(0, line.unicodeScalars.count) 143 | if let match = pattern.firstMatch(in: line, range: range) { 144 | if match.numberOfRanges == 5 { 145 | for i in 0..<5 { 146 | if match.rangeAt(i).location == NSNotFound { 147 | return nil 148 | } 149 | } 150 | return FunctionHead( 151 | line, 152 | lineNumber, 153 | match.rangeAt(0).location, 154 | line[match.rangeAt(1)], 155 | line[match.rangeAt(2)], 156 | line[match.rangeAt(3)], 157 | line[match.rangeAt(4)] 158 | ) 159 | } 160 | } 161 | return nil 162 | } 163 | 164 | let rules = [ 165 | needlessWordsInFirstLabel, 166 | needlessWordsInName 167 | ] 168 | 169 | typealias SuggestionFormatter = (Suggestion, String?) -> String 170 | 171 | func dollarSeparatedFormatter(suggestion: Suggestion, path: String?) -> String { 172 | let (old, new, reason) = suggestion 173 | return "\(reason)$\(path ?? "")$\(old.line)$\(old.pos)$\(old)$\(new)" 174 | } 175 | 176 | func xcodeWarningFormatter(suggestion: Suggestion, path: String?) -> String { 177 | let (old, new, reason) = suggestion 178 | let parts = [ 179 | path ?? "", 180 | "\(old.line+1)", 181 | "\(old.pos+1)", 182 | " warning", 183 | " \(reason) '\(old) …'; perhaps use '\(new) …' instead?" 184 | ] 185 | return parts.joined(separator: ":") 186 | } 187 | 188 | func readableFormatter(suggestion: Suggestion, path: String?) -> String { 189 | let (old, new, reason) = suggestion 190 | let parts = [ 191 | "\(reason) \(path == nil ? "" : "in " + path! + " ")(line \(old.line))", 192 | "\(old.original)", 193 | "\(String(repeating: " ", count: old.pos))^", 194 | "possible alternative: \(new) …", 195 | "", 196 | ] 197 | return parts.joined(separator: "\n") 198 | } 199 | 200 | func processLine(path: String?, line: String, lineNumber: Int, 201 | formatter: @escaping SuggestionFormatter, diffMode: Bool) 202 | { 203 | if diffMode && !line.hasPrefix("+") && !line.hasPrefix("!") && 204 | !line.hasPrefix(">") 205 | { 206 | return 207 | } 208 | head(from: line, lineNumber: lineNumber) 209 | .flatMap { suggest($0, rules: rules) } 210 | .map { $0.flatMap { formatter($0, path) } } 211 | .map { $0.forEach { print($0) } } 212 | } 213 | 214 | let formatters: [String: SuggestionFormatter] = [ 215 | "-Xcode": xcodeWarningFormatter, 216 | "-dollar": dollarSeparatedFormatter, 217 | "-readable": readableFormatter, 218 | ] 219 | 220 | func main() { 221 | let options = CommandLine.arguments.filter { $0.hasPrefix("-") } 222 | if options.contains("-h") || options.contains("--help") { 223 | print([ 224 | "Find needless words that merely repeats type information in your Swift function names.", 225 | "", 226 | "Useage: needless [options] file1 [file2 file3 ...]", 227 | "", 228 | "options:", 229 | "\t-readable print result in a human readable format", 230 | "\t-Xcode print result in clang/swiftc style errors", 231 | "\t-dollar print result in '$' separated fields, specifically:", 232 | "\t [description]$[path]$[line number]$[column number]$[original name]$[suggested name]", 233 | "\t-diff only check lines that's an addition in diff/patch formats", 234 | "\t-h --help print this message.", 235 | ].joined(separator: "\n")) 236 | return 237 | } 238 | 239 | let files = CommandLine.arguments.filter { !$0.hasPrefix("-") } 240 | 241 | let formatter: SuggestionFormatter = { 242 | let found = options.flatMap { formatters[$0] } 243 | return found.isEmpty ? readableFormatter : found[0] 244 | }() 245 | 246 | let diffMode = options.contains("-diff") 247 | 248 | var count = 0 249 | if files.count <= 1 { 250 | while let line = readLine() { 251 | processLine(path: nil, line: line, lineNumber: count, 252 | formatter: formatter, diffMode: diffMode) 253 | count += 1 254 | } 255 | } else { 256 | for path in files.suffix(from: 1) { 257 | do { 258 | for line in try String(contentsOfFile: path) 259 | .components(separatedBy: .newlines) 260 | { 261 | processLine(path: path, line: line, lineNumber: count, 262 | formatter: formatter, diffMode: diffMode) 263 | count += 1 264 | } 265 | } catch { 266 | print("Error opening file \(path)") 267 | } 268 | } 269 | } 270 | } 271 | 272 | main() 273 | --------------------------------------------------------------------------------