diff --git a/CHANGELOG.md b/CHANGELOG.md index 5afba1d40..b440412e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,10 @@ [Marcelo Fabri](https://github.com/marcelofabri) [#1065](https://github.com/realm/SwiftLint/issues/1065) +* Add `generic_type_name` rule that validates generic constraint type names. + [Marcelo Fabri](https://github.com/marcelofabri) + [#51](https://github.com/realm/SwiftLint/issues/51) + ##### Bug Fixes * Ignore close parentheses on `vertical_parameter_alignment` rule. diff --git a/Source/SwiftLintFramework/Models/MasterRuleList.swift b/Source/SwiftLintFramework/Models/MasterRuleList.swift index b451ecf01..90558cca5 100644 --- a/Source/SwiftLintFramework/Models/MasterRuleList.swift +++ b/Source/SwiftLintFramework/Models/MasterRuleList.swift @@ -95,6 +95,7 @@ public let masterRuleList = RuleList(rules: ForceUnwrappingRule.self, FunctionBodyLengthRule.self, FunctionParameterCountRule.self, + GenericTypeNameRule.self, ImplicitGetterRule.self, LargeTupleRule.self, LeadingWhitespaceRule.self, diff --git a/Source/SwiftLintFramework/Rules/GenericTypeNameRule.swift b/Source/SwiftLintFramework/Rules/GenericTypeNameRule.swift new file mode 100644 index 000000000..757fd879c --- /dev/null +++ b/Source/SwiftLintFramework/Rules/GenericTypeNameRule.swift @@ -0,0 +1,206 @@ +// +// GenericTypeNameRule.swift +// SwiftLint +// +// Created by Marcelo Fabri on 12/25/16. +// Copyright © 2016 Realm. All rights reserved. +// + +import Foundation +import SourceKittenFramework + +public struct GenericTypeNameRule: ASTRule, ConfigurationProviderRule { + public var configuration = NameConfiguration(minLengthWarning: 1, + minLengthError: 0, + maxLengthWarning: 20, + maxLengthError: 1000) + + public init() {} + + public static let description = RuleDescription( + identifier: "generic_type_name", + name: "Generic Type Name", + description: "Generic type name should only contain alphanumeric characters, start with an " + + "uppercase character and span between 1 and 20 characters in length.", + nonTriggeringExamples: [ + "func foo() {}\n", + "func foo() -> T {}\n", + "func foo(param: U) -> T {}\n", + "func foo(param: U) -> T {}\n", + "struct Foo {}\n", + "class Foo {}\n", + "func run(_ options: NoOptions>) {}\n", + "func foo(_ options: Set) {}\n", + "func < (lhs: T?, rhs: T?) -> Bool\n", + "func configureWith(data: Either)\n" + ], + triggeringExamples: [ + "func foo<↓T_Foo>() {}\n", + "func foo(param: U_Foo) -> T {}\n", + "func foo<↓\(String(repeating: "T", count: 21))>() {}\n", + "func foo<↓type>() {}\n" + ] + ["class", "struct"].flatMap { type in + [ + "\(type) Foo<↓T_Foo> {}\n", + "\(type) Foo {}\n", + "\(type) Foo<↓T_Foo, ↓U_Foo> {}\n", + "\(type) Foo<↓\(String(repeating: "T", count: 21))> {}\n", + "\(type) Foo<↓type> {}\n" + ] + } + ) + + private let pattern = regex("<(\\s*\\w.*?)>") + + public func validateFile(_ file: File, + kind: SwiftDeclarationKind, + dictionary: [String: SourceKitRepresentable]) -> [StyleViolation] { + let types = genericTypesForType(file, kind: kind, dictionary: dictionary) + + genericTypesForFunction(file, kind: kind, dictionary: dictionary) + + return types.flatMap { validateName(name: $0.0, file: file, offset: $0.1) } + } + + private func genericTypesForType(_ file: File, + kind: SwiftDeclarationKind, + dictionary: [String: SourceKitRepresentable]) -> [(String, Int)] { + guard kind == .class || kind == .struct, + let nameOffset = (dictionary["key.nameoffset"] as? Int64).flatMap({ Int($0) }), + let nameLength = (dictionary["key.namelength"] as? Int64).flatMap({ Int($0) }), + let bodyOffset = (dictionary["key.bodyoffset"] as? Int64).flatMap({ Int($0) }), + case let contents = file.contents.bridge(), + case let start = nameOffset + nameLength, + case let length = bodyOffset - start, + let range = contents.byteRangeToNSRange(start: start, length: length), + let match = pattern.firstMatch(in: file.contents, options: [], range: range)?.rangeAt(1) else { + return [] + } + + let genericConstraint = contents.substring(with: match) + return extractTypesFromGenericConstraint(genericConstraint, offset: match.location, file: file) + } + + private func genericTypesForFunction(_ file: File, + kind: SwiftDeclarationKind, + dictionary: [String: SourceKitRepresentable]) -> [(String, Int)] { + guard SwiftDeclarationKind.functionKinds().contains(kind), + let offset = (dictionary["key.nameoffset"] as? Int64).flatMap({ Int($0) }), + let length = (dictionary["key.namelength"] as? Int64).flatMap({ Int($0) }), + case let contents = file.contents.bridge(), + let range = contents.byteRangeToNSRange(start: offset, length: length), + let match = pattern.firstMatch(in: file.contents, options: [], range: range)?.rangeAt(1), + match.location < minParameterOffset(parameters: dictionary.enclosedVarParameters, file: file) else { + return [] + } + + let genericConstraint = contents.substring(with: match) + return extractTypesFromGenericConstraint(genericConstraint, offset: match.location, file: file) + } + + private func minParameterOffset(parameters: [[String: SourceKitRepresentable]], file: File) -> Int { + let offsets = parameters.flatMap { + ($0["key.offset"] as? Int64).flatMap({ Int($0) }) + }.flatMap { + file.contents.bridge().byteRangeToNSRange(start: $0, length: 0)?.location + } + + return offsets.min() ?? Int.max + } + + private func extractTypesFromGenericConstraint(_ constraint: String, offset: Int, file: File) -> [(String, Int)] { + guard let beforeWhere = constraint.components(separatedBy: "where").first else { + return [] + } + + let namesAndRanges: [(String, NSRange)] = beforeWhere.split(separator: ",").flatMap { string, range in + return string.split(separator: ":").first.map { + let (trimmed, trimmedRange) = $0.0.trimmingWhitespaces() + return (trimmed, NSRange(location: range.location + trimmedRange.location, + length: trimmedRange.length)) + } + } + + let contents = file.contents.bridge() + return namesAndRanges.flatMap { (name, range) -> (String, Int)? in + guard let byteRange = contents.NSRangeToByteRange(start: range.location + offset, + length: range.length), + case let kinds = file.syntaxMap.tokensIn(byteRange).flatMap({ SyntaxKind(rawValue: $0.type) }), + kinds == [.identifier] else { + return nil + } + + return (name, byteRange.location) + } + } + + private func validateName(name: String, file: File, offset: Int) -> [StyleViolation] { + guard !configuration.excluded.contains(name) else { + return [] + } + + let nameCharacterSet = CharacterSet(charactersIn: name) + if !CharacterSet.alphanumerics.isSuperset(of: nameCharacterSet) { + return [ + StyleViolation(ruleDescription: type(of: self).description, + severity: .error, + location: Location(file: file, byteOffset: offset), + reason: "Generic type name should only contain alphanumeric characters: '\(name)'") + ] + } else if !name.substring(to: name.index(after: name.startIndex)).isUppercase() { + return [ + StyleViolation(ruleDescription: type(of: self).description, + severity: .error, + location: Location(file: file, byteOffset: offset), + reason: "Generic type name should start with an uppercase character: '\(name)'") + ] + } else if let severity = severity(forLength: name.characters.count) { + return [ + StyleViolation(ruleDescription: type(of: self).description, + severity: severity, + location: Location(file: file, byteOffset: offset), + reason: "Generic type name should be between \(configuration.minLengthThreshold) and " + + "\(configuration.maxLengthThreshold) characters long: '\(name)'") + ] + } + + return [] + } +} + +extension String { + fileprivate func split(separator: Character) -> [(String, NSRange)] { + var offsets = [0] + var ends = [Int]() + var currentOffset = 0 + let components = characters.split { character in + currentOffset += 1 + if character == separator { + offsets.append(currentOffset) + ends.append(currentOffset - 1) + return true + } + + return false + }.map(String.init) + + ends.append(characters.count) + + let ranges = offsets.enumerated().map { index, offset -> NSRange in + let next = ends[index] + return NSRange(location: offset, length: next - offset) + } + + return Array(zip(components, ranges)) + } + + fileprivate func trimmingWhitespaces() -> (String, NSRange) { + let range = NSRange(location: 0, length: bridge().length) + guard let match = regex("^\\s*(\\S*)\\s*$").firstMatch(in: self, options: [], range: range), + NSEqualRanges(range, match.range) else { + return (self, range) + } + + let trimmedRange = match.rangeAt(1) + return (bridge().substring(with: trimmedRange), trimmedRange) + } +} diff --git a/SwiftLint.xcodeproj/project.pbxproj b/SwiftLint.xcodeproj/project.pbxproj index 3ec12b9d9..468c0e990 100644 --- a/SwiftLint.xcodeproj/project.pbxproj +++ b/SwiftLint.xcodeproj/project.pbxproj @@ -120,6 +120,8 @@ D4B022981E102EE8007E5297 /* ObjectLiteralRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B022971E102EE8007E5297 /* ObjectLiteralRule.swift */; }; D4B022B01E109816007E5297 /* CharacterSet+LinuxHack.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B022AF1E109816007E5297 /* CharacterSet+LinuxHack.swift */; }; D4B022B21E10B613007E5297 /* RedundantVoidReturnRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B022B11E10B613007E5297 /* RedundantVoidReturnRule.swift */; }; + D4B022981E102EE8007E5297 /* ObjectLiteralRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B022971E102EE8007E5297 /* ObjectLiteralRule.swift */; }; + D4B022A41E105636007E5297 /* GenericTypeNameRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B022A31E105636007E5297 /* GenericTypeNameRule.swift */; }; D4C4A34C1DEA4FF000E0E04C /* AttributesConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4C4A34A1DEA4FD700E0E04C /* AttributesConfiguration.swift */; }; D4C4A34E1DEA877200E0E04C /* FileHeaderRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4C4A34D1DEA877200E0E04C /* FileHeaderRule.swift */; }; D4C4A3521DEFBBB700E0E04C /* FileHeaderConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4C4A3511DEFBBB700E0E04C /* FileHeaderConfiguration.swift */; }; @@ -374,6 +376,8 @@ D4B022971E102EE8007E5297 /* ObjectLiteralRule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjectLiteralRule.swift; sourceTree = ""; }; D4B022AF1E109816007E5297 /* CharacterSet+LinuxHack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CharacterSet+LinuxHack.swift"; sourceTree = ""; }; D4B022B11E10B613007E5297 /* RedundantVoidReturnRule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedundantVoidReturnRule.swift; sourceTree = ""; }; + D4B022971E102EE8007E5297 /* ObjectLiteralRule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjectLiteralRule.swift; sourceTree = ""; }; + D4B022A31E105636007E5297 /* GenericTypeNameRule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GenericTypeNameRule.swift; sourceTree = ""; }; D4C4A34A1DEA4FD700E0E04C /* AttributesConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributesConfiguration.swift; sourceTree = ""; }; D4C4A34D1DEA877200E0E04C /* FileHeaderRule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileHeaderRule.swift; sourceTree = ""; }; D4C4A3511DEFBBB700E0E04C /* FileHeaderConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileHeaderConfiguration.swift; sourceTree = ""; }; @@ -774,6 +778,7 @@ B58AEED51C492C7B00E901FD /* ForceUnwrappingRule.swift */, E88DEA8F1B099A3100A66CB0 /* FunctionBodyLengthRule.swift */, 2E5761A91C573B83003271AF /* FunctionParameterCountRule.swift */, + D4B022A31E105636007E5297 /* GenericTypeNameRule.swift */, D43DB1071DC573DA00281215 /* ImplicitGetterRule.swift */, D4DA1DF91E18D6200037413D /* LargeTupleRule.swift */, E88DEA7D1B098F2A00A66CB0 /* LeadingWhitespaceRule.swift */, @@ -1133,6 +1138,7 @@ E88198601BEA98F000333A11 /* VariableNameRule.swift in Sources */, E88DEA791B098D4400A66CB0 /* RuleParameter.swift in Sources */, D4DA1DFA1E18D6200037413D /* LargeTupleRule.swift in Sources */, + D4B022A41E105636007E5297 /* GenericTypeNameRule.swift in Sources */, E86396CB1BADB519002C9E88 /* CSVReporter.swift in Sources */, 37B3FA8B1DFD45A700AD30D2 /* Dictionary+SwiftLint.swift in Sources */, D4B022B01E109816007E5297 /* CharacterSet+LinuxHack.swift in Sources */, diff --git a/Tests/SwiftLintFrameworkTests/RulesTests.swift b/Tests/SwiftLintFrameworkTests/RulesTests.swift index c63448362..e2e8c48c4 100644 --- a/Tests/SwiftLintFrameworkTests/RulesTests.swift +++ b/Tests/SwiftLintFrameworkTests/RulesTests.swift @@ -102,6 +102,10 @@ class RulesTests: XCTestCase { verifyRule(FunctionParameterCountRule.description) } + func testGenericTypeName() { + verifyRule(GenericTypeNameRule.description) + } + func testImplicitGetter() { verifyRule(ImplicitGetterRule.description) } @@ -363,6 +367,7 @@ extension RulesTests { // ("testForceUnwrapping", testForceUnwrapping), ("testFunctionBodyLength", testFunctionBodyLength), ("testFunctionParameterCount", testFunctionParameterCount), + ("testGenericTypeName", testGenericTypeName), ("testImplicitGetter", testImplicitGetter), ("testLargeTuple", testLargeTuple), ("testLeadingWhitespace", testLeadingWhitespace),