diff --git a/Source/SwiftLintFramework/Rules/AttributesRule.swift b/Source/SwiftLintFramework/Rules/AttributesRule.swift index db017f76e..e3c545a2d 100644 --- a/Source/SwiftLintFramework/Rules/AttributesRule.swift +++ b/Source/SwiftLintFramework/Rules/AttributesRule.swift @@ -11,6 +11,7 @@ import SourceKittenFramework private enum AttributesRuleError: ErrorType { case UnexpectedBlankLine + case MoreThanOneAttributeInSameLine } public struct AttributesRule: ASTRule, OptInRule, ConfigurationProviderRule { @@ -23,102 +24,10 @@ public struct AttributesRule: ASTRule, OptInRule, ConfigurationProviderRule { name: "Attributes", description: "Attributes should be on their own lines in functions and types, " + "but on the same line as variables and imports", - nonTriggeringExamples: AttributesRule.nonTriggeringExamples(), - triggeringExamples: AttributesRule.triggeringExamples() + nonTriggeringExamples: AttributesRuleExamples.nonTriggeringExamples(), + triggeringExamples: AttributesRuleExamples.triggeringExamples() ) - private static func nonTriggeringExamples() -> [String] { - let common = [ - "@objc var x: String", - "@objc private var x: String", - "@nonobjc var x: String", - "@IBOutlet private var label: UILabel", - "@NSCopying var name: NSString", - "@NSManaged var name: String?", - "@IBInspectable var cornerRadius: CGFloat", - "@available(iOS 9.0, *)\n let stackView: UIStackView", - "@NSManaged func addSomeObject(book: SomeObject)", - "@IBAction func buttonPressed(button: UIButton)", - "@available(iOS 9.0, *)\n func animate(view: UIStackView)", - "@available(iOS 9.0, *, message=\"A message\")\n func animate(view: UIStackView)", - "@nonobjc\n final class X", - "@available(iOS 9.0, *)\n class UIStackView", - "@NSApplicationMain\n class AppDelegate: NSObject, NSApplicationDelegate", - "@UIApplicationMain\n class AppDelegate: NSObject, UIApplicationDelegate", - "@IBDesignable\n class MyCustomView: UIView", - "@testable import SourceKittenFramework" - ] - - #if swift(>=3.0) - let swift3Only = [ - "@GKInspectable var maxSpeed: Float", - "@discardableResult\n func a() -> Int", - "@objc\n @discardableResult\n func a() -> Int", - "func increase(f: @autoclosure () -> Int) -> Int", - "func foo(completionHandler: @escaping () -> Void)" - ] - - return common + swift3Only - #else - let swift2Only = [ - "@warn_unused_result\n func a() -> Int", - "@objc\n @warn_unused_result\n func a() -> Int", - "func increase(@autoclosure f: () -> Int ) -> Int", - "func foo(@noescape x: Int -> Int)", - "@noreturn\n func exit(_: Int)", - "func exit(_: Int) -> @noreturn Int" - ] - - return common + swift2Only - #endif - } - - private static func triggeringExamples() -> [String] { - let common = [ - "@objc\n var x: String", - "@objc\n\n var x: String", - "@objc\n private var x: String", - "@nonobjc\n var x: String", - "@IBOutlet\n private var label: UILabel", - "@IBOutlet\n\n private var label: UILabel", - "@NSCopying\n var name: NSString", - "@NSManaged\n var name: String?", - "@IBInspectable\n var cornerRadius: CGFloat", - "@available(iOS 9.0, *) let stackView: UIStackView", - "@NSManaged\n func addSomeObject(book: SomeObject)", - "@IBAction\n func buttonPressed(button: UIButton)", - "@available(iOS 9.0, *) func animate(view: UIStackView)", - "@nonobjc final class X", - "@available(iOS 9.0, *) class UIStackView", - "@available(iOS 9.0, *)\n\n class UIStackView", - "@UIApplicationMain class AppDelegate: NSObject, UIApplicationDelegate", - "@IBDesignable class MyCustomView: UIView", - "@testable\nimport SourceKittenFramework", - "@testable\n\n\nimport SourceKittenFramework" - ] - - #if swift(>=3.0) - let swift3Only = [ - "@GKInspectable\n var maxSpeed: Float", - "@discardableResult func a() -> Int", - "@objc\n @discardableResult func a() -> Int", - "@objc\n\n @discardableResult\n func a() -> Int", - ] - - return common + swift3Only - #else - let swift2Only = [ - "@warn_unused_result func a() -> Int", - "@warn_unused_result(message=\"You should use this\") func a() -> Int", - "@objc\n @warn_unused_result func a() -> Int", - "@objc\n\n @warn_unused_result\n func a() -> Int", - "@noreturn func exit(_: Int)" - ] - - return common + swift2Only - #endif - } - public func validateFile(file: File) -> [StyleViolation] { return validateTestableImport(file) + validateFile(file, dictionary: file.structure.dictionary) @@ -175,25 +84,40 @@ public struct AttributesRule: ASTRule, OptInRule, ConfigurationProviderRule { let line = file.lines[lineNumber - 1] let tokens = file.syntaxMap.tokensIn(line.byteRange) - let attributesTokens = tokens.flatMap { attributeName($0, file: file) } - let isViolation: Bool + let attributesTokens = Set(tokens.flatMap { attributeName($0, file: file) }) + var isViolation = false do { - let previousAttributes = try attributesFromPreviousLines(lineNumber - 1, file: file) + let previousAttributes = try Set(attributesFromPreviousLines(lineNumber - 1, + file: file)) - let alwaysInSameLineAttributes = ["@IBAction", "@NSManaged"] - let alwaysInNewLineAttributes = ["@available"] + let alwaysInSameLineAttributes = Set(arrayLiteral: "@IBAction", "@NSManaged") + let alwaysInNewLineAttributes = attributesTokens.union(previousAttributes).filter { + if $0.containsString("(") { + // some tokens contains parameters in themselves (i.e. warn_unused_result) + return true + } - if !previousAttributes.filter(alwaysInNewLineAttributes.contains).isEmpty { - isViolation = false - } else if !attributesTokens.filter(alwaysInNewLineAttributes.contains).isEmpty { + return ["@available"].contains($0) + } + + if !attributesTokens.intersect(alwaysInNewLineAttributes).isEmpty { isViolation = true - } else if !previousAttributes.filter(alwaysInSameLineAttributes.contains).isEmpty { + } else if !previousAttributes.intersect(alwaysInSameLineAttributes).isEmpty { isViolation = true - } else if !attributesTokens.filter(alwaysInSameLineAttributes.contains).isEmpty { - isViolation = false } else { - isViolation = attributesTokens.isEmpty == attributeShouldBeOnSameLine + + // ignore whitelisted attributes + let operation: Set -> Set -> Set = + attributeShouldBeOnSameLine ? Set.union : Set.subtract + + let attributesAfterWhitelist = operation( + operation(attributesTokens)( + previousAttributes.intersect(alwaysInNewLineAttributes) + ) + )(attributesTokens.intersect(alwaysInSameLineAttributes)) + + isViolation = attributesAfterWhitelist.isEmpty == attributeShouldBeOnSameLine } } catch { isViolation = true @@ -204,6 +128,11 @@ public struct AttributesRule: ASTRule, OptInRule, ConfigurationProviderRule { } // Violation found! + return violation(dictionary, file: file) + } + + private func violation(dictionary: [String: SourceKitRepresentable], + file: File) -> [StyleViolation] { let location: Location if let offset = (dictionary["key.offset"] as? Int64).flatMap({ Int($0) }) { location = Location(file: file, byteOffset: offset) @@ -232,11 +161,16 @@ public struct AttributesRule: ASTRule, OptInRule, ConfigurationProviderRule { } let attributesTokens = tokens.flatMap { attributeName($0, file: file) } - // found a line that does not contain an attribute token - we can stop looking if attributesTokens.isEmpty { + // found a line that does not contain an attribute token - we can stop looking break } + if attributesTokens.count > 1 { + // we don't allow multiple attributes in the same line if it's a previous line + throw AttributesRuleError.MoreThanOneAttributeInSameLine + } + allTokens.insertContentsOf(attributesTokens, at: 0) currentLine -= 1 } @@ -282,3 +216,102 @@ public struct AttributesRule: ASTRule, OptInRule, ConfigurationProviderRule { return attributes.filter { !blacklisted.contains($0) } } } + +private struct AttributesRuleExamples { + static func nonTriggeringExamples() -> [String] { + let common = [ + "@objc var x: String", + "@objc private var x: String", + "@nonobjc var x: String", + "@IBOutlet private var label: UILabel", + "@IBOutlet @objc private var label: UILabel", + "@NSCopying var name: NSString", + "@NSManaged var name: String?", + "@IBInspectable var cornerRadius: CGFloat", + "@available(iOS 9.0, *)\n let stackView: UIStackView", + "@NSManaged func addSomeObject(book: SomeObject)", + "@IBAction func buttonPressed(button: UIButton)", + "@objc\n @IBAction func buttonPressed(button: UIButton)", + "@available(iOS 9.0, *)\n func animate(view: UIStackView)", + "@available(iOS 9.0, *, message=\"A message\")\n func animate(view: UIStackView)", + "@nonobjc\n final class X", + "@available(iOS 9.0, *)\n class UIStackView", + "@NSApplicationMain\n class AppDelegate: NSObject, NSApplicationDelegate", + "@UIApplicationMain\n class AppDelegate: NSObject, UIApplicationDelegate", + "@IBDesignable\n class MyCustomView: UIView", + "@testable import SourceKittenFramework" + ] + + #if swift(>=3.0) + let swift3Only = [ + "@GKInspectable var maxSpeed: Float", + "@discardableResult\n func a() -> Int", + "@objc\n @discardableResult\n func a() -> Int", + "func increase(f: @autoclosure () -> Int) -> Int", + "func foo(completionHandler: @escaping () -> Void)" + ] + + return common + swift3Only + #else + let swift2Only = [ + "@warn_unused_result\n func a() -> Int", + "@objc\n @warn_unused_result\n func a() -> Int", + "func increase(@autoclosure f: () -> Int ) -> Int", + "func foo(@noescape x: Int -> Int)", + "@noreturn\n func exit(_: Int)", + "func exit(_: Int) -> @noreturn Int" + ] + + return common + swift2Only + #endif + } + + static func triggeringExamples() -> [String] { + let common = [ + "@objc\n var x: String", + "@objc\n\n var x: String", + "@objc\n private var x: String", + "@nonobjc\n var x: String", + "@IBOutlet\n private var label: UILabel", + "@IBOutlet\n\n private var label: UILabel", + "@NSCopying\n var name: NSString", + "@NSManaged\n var name: String?", + "@IBInspectable\n var cornerRadius: CGFloat", + "@available(iOS 9.0, *) let stackView: UIStackView", + "@NSManaged\n func addSomeObject(book: SomeObject)", + "@IBAction\n func buttonPressed(button: UIButton)", + "@IBAction\n @objc\n func buttonPressed(button: UIButton)", + "@available(iOS 9.0, *) func animate(view: UIStackView)", + "@nonobjc final class X", + "@available(iOS 9.0, *) class UIStackView", + "@available(iOS 9.0, *)\n @objc class UIStackView", + "@available(iOS 9.0, *) @objc\n class UIStackView", + "@available(iOS 9.0, *)\n\n class UIStackView", + "@UIApplicationMain class AppDelegate: NSObject, UIApplicationDelegate", + "@IBDesignable class MyCustomView: UIView", + "@testable\nimport SourceKittenFramework", + "@testable\n\n\nimport SourceKittenFramework" + ] + + #if swift(>=3.0) + let swift3Only = [ + "@GKInspectable\n var maxSpeed: Float", + "@discardableResult func a() -> Int", + "@objc\n @discardableResult func a() -> Int", + "@objc\n\n @discardableResult\n func a() -> Int", + ] + + return common + swift3Only + #else + let swift2Only = [ + "@warn_unused_result func a() -> Int", + "@warn_unused_result(message=\"You should use this\") func a() -> Int", + "@objc\n @warn_unused_result func a() -> Int", + "@objc\n\n @warn_unused_result\n func a() -> Int", + "@noreturn func exit(_: Int)" + ] + + return common + swift2Only + #endif + } +}