Skip to content

Commit

Permalink
Merge pull request #2591 from marcelofabri/mf-deployment-target-rule
Browse files Browse the repository at this point in the history
Add `deployment_target` rule
  • Loading branch information
marcelofabri authored Jan 26, 2019
2 parents a1b659a + 1495f62 commit 7c38722
Show file tree
Hide file tree
Showing 9 changed files with 488 additions and 1 deletion.
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@

#### Enhancements

* None.
* Add `deployment_target` rule to validate that `@availability` attributes and
`#available` conditions are not using a version that is satisfied by the
deployment target. Since SwiftLint can't read an Xcode project, you need to
configure this rule with these keys: `iOS_deployment_target`,
`macOS_deployment_target`, `watchOS_deployment_target` and
`tvOS_deployment_target`. By default, these values are configured with the
minimum versions supported by Swift.
[Marcelo Fabri](https://github.com/marcelofabri)
[#2589](https://github.com/realm/SwiftLint/issues/2589)

#### Bug Fixes

Expand Down
110 changes: 110 additions & 0 deletions Rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
* [Convenience Type](#convenience-type)
* [Custom Rules](#custom-rules)
* [Cyclomatic Complexity](#cyclomatic-complexity)
* [Deployment Target](#deployment-target)
* [Discarded Notification Center Observer](#discarded-notification-center-observer)
* [Discouraged Direct Initialization](#discouraged-direct-initialization)
* [Discouraged Object Literal](#discouraged-object-literal)
Expand Down Expand Up @@ -2900,6 +2901,115 @@ if true {}; if true {}; if true {}; if true {}; if true {}



## Deployment Target

Identifier | Enabled by default | Supports autocorrection | Kind | Analyzer | Minimum Swift Compiler Version
--- | --- | --- | --- | --- | ---
`deployment_target` | Enabled | No | lint | No | 4.1.0

Availability checks or attributes shouldn't be using older versions that are satisfied by the deployment target.

### Examples

<details>
<summary>Non Triggering Examples</summary>

```swift
@available(iOS 12.0, *)
class A {}
```

```swift
@available(watchOS 4.0, *)
class A {}
```

```swift
@available(swift 3.0.2)
class A {}
```

```swift
class A {}
```

```swift
if #available(iOS 10.0, *) {}
```

```swift
if #available(iOS 10, *) {}
```

```swift
guard #available(iOS 12.0, *) else { return }
```

</details>
<details>
<summary>Triggering Examples</summary>

```swift
↓@available(iOS 6.0, *)
class A {}
```

```swift
↓@available(iOS 7.0, *)
class A {}
```

```swift
↓@available(iOS 6, *)
class A {}
```

```swift
↓@available(iOS 6.0, macOS 10.12, *)
class A {}
```

```swift
↓@available(macOS 10.12, iOS 6.0, *)
class A {}
```

```swift
↓@available(macOS 10.7, *)
class A {}
```

```swift
↓@available(OSX 10.7, *)
class A {}
```

```swift
↓@available(watchOS 0.9, *)
class A {}
```

```swift
↓@available(tvOS 8, *)
class A {}
```

```swift
if ↓#available(iOS 6.0, *) {}
```

```swift
if ↓#available(iOS 6, *) {}
```

```swift
guard ↓#available(iOS 6.0, *) else { return }
```

</details>



## Discarded Notification Center Observer

Identifier | Enabled by default | Supports autocorrection | Kind | Analyzer | Minimum Swift Compiler Version
Expand Down
1 change: 1 addition & 0 deletions Source/SwiftLintFramework/Models/MasterRuleList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public let masterRuleList = RuleList(rules: [
ConvenienceTypeRule.self,
CustomRules.self,
CyclomaticComplexityRule.self,
DeploymentTargetRule.self,
DiscardedNotificationCenterObserverRule.self,
DiscouragedDirectInitRule.self,
DiscouragedObjectLiteralRule.self,
Expand Down
141 changes: 141 additions & 0 deletions Source/SwiftLintFramework/Rules/Lint/DeploymentTargetRule.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import Foundation
import SourceKittenFramework

public struct DeploymentTargetRule: ConfigurationProviderRule {
private typealias Version = DeploymentTargetConfiguration.Version
public var configuration = DeploymentTargetConfiguration()

public init() {}

public static let description = RuleDescription(
identifier: "deployment_target",
name: "Deployment Target",
description: "Availability checks or attributes shouldn't be using older versions " +
"that are satisfied by the deployment target.",
kind: .lint,
minSwiftVersion: .fourDotOne,
nonTriggeringExamples: [
"@available(iOS 12.0, *)\nclass A {}",
"@available(watchOS 4.0, *)\nclass A {}",
"@available(swift 3.0.2)\nclass A {}",
"class A {}",
"if #available(iOS 10.0, *) {}",
"if #available(iOS 10, *) {}",
"guard #available(iOS 12.0, *) else { return }"
],
triggeringExamples: [
"↓@available(iOS 6.0, *)\nclass A {}",
"↓@available(iOS 7.0, *)\nclass A {}",
"↓@available(iOS 6, *)\nclass A {}",
"↓@available(iOS 6.0, macOS 10.12, *)\n class A {}",
"↓@available(macOS 10.12, iOS 6.0, *)\n class A {}",
"↓@available(macOS 10.7, *)\nclass A {}",
"↓@available(OSX 10.7, *)\nclass A {}",
"↓@available(watchOS 0.9, *)\nclass A {}",
"↓@available(tvOS 8, *)\nclass A {}",
"if ↓#available(iOS 6.0, *) {}",
"if ↓#available(iOS 6, *) {}",
"guard ↓#available(iOS 6.0, *) else { return }"
]
)

public func validate(file: File) -> [StyleViolation] {
var violations = validateAttributes(file: file, dictionary: file.structure.dictionary)
violations += validateConditions(file: file)
violations.sort(by: { $0.location < $1.location })

return violations
}

private func validateConditions(file: File) -> [StyleViolation] {
let pattern = "#available\\s*\\([^\\(]+\\)"

return file.rangesAndTokens(matching: pattern).flatMap { range, tokens -> [StyleViolation] in
guard let availabilityToken = tokens.first,
SyntaxKind(rawValue: availabilityToken.type) == .keyword,
let tokenRange = file.contents.bridge().byteRangeToNSRange(start: availabilityToken.offset,
length: availabilityToken.length) else {
return []
}

let rangeToSearch = NSRange(location: tokenRange.upperBound, length: range.length - tokenRange.length)
return validate(range: rangeToSearch, file: file, violationType: "condition",
byteOffsetToReport: availabilityToken.offset)
}
}

private func validateAttributes(file: File, dictionary: [String: SourceKitRepresentable]) -> [StyleViolation] {
return dictionary.substructure.flatMap { subDict -> [StyleViolation] in
var violations = validateAttributes(file: file, dictionary: subDict)

if let kindString = subDict.kind,
let kind = SwiftDeclarationKind(rawValue: kindString) {
violations += validateAttributes(file: file, kind: kind, dictionary: subDict)
}

return violations
}
}

private func validateAttributes(file: File,
kind: SwiftDeclarationKind,
dictionary: [String: SourceKitRepresentable]) -> [StyleViolation] {
let attributes = dictionary.swiftAttributes.filter {
$0.attribute.flatMap(SwiftDeclarationAttributeKind.init) == .available
}
guard !attributes.isEmpty else {
return []
}

let contents = file.contents.bridge()
return attributes.flatMap { dictionary -> [StyleViolation] in
guard let offset = dictionary.offset, let length = dictionary.length,
let range = contents.byteRangeToNSRange(start: offset, length: length) else {
return []
}

return validate(range: range, file: file, violationType: "attribute", byteOffsetToReport: offset)
}.unique
}

private func validate(range: NSRange, file: File, violationType: String,
byteOffsetToReport: Int) -> [StyleViolation] {
let platformToConfiguredMinVersion = self.platformToConfiguredMinVersion
let allPlatforms = "(?:" + platformToConfiguredMinVersion.keys.joined(separator: "|") + ")"
let pattern = "\(allPlatforms) [\\d\\.]+"

return file.rangesAndTokens(matching: pattern, range: range).compactMap { _, tokens -> StyleViolation? in
guard tokens.count == 2,
tokens.kinds == [.keyword, .number],
let platform = file.contents(for: tokens[0]),
let minVersion = platformToConfiguredMinVersion[platform],
let versionString = file.contents(for: tokens[1]) else {
return nil
}

guard let version = try? Version(rawValue: versionString),
version <= minVersion else {
return nil
}

let reason = """
Availability \(violationType) is using a version (\(versionString)) that is \
satisfied by the deployment target (\(minVersion.stringValue)) for platform \(platform).
"""
return StyleViolation(ruleDescription: type(of: self).description,
severity: configuration.severityConfiguration.severity,
location: Location(file: file, byteOffset: byteOffsetToReport),
reason: reason)
}
}

private var platformToConfiguredMinVersion: [String: Version] {
return [
"iOS": configuration.iOSDeploymentTarget,
"macOS": configuration.macOSDeploymentTarget,
"OSX": configuration.macOSDeploymentTarget,
"tvOS": configuration.tvOSDeploymentTarget,
"watchOS": configuration.watchOSDeploymentTarget
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
public struct DeploymentTargetConfiguration: RuleConfiguration, Equatable {
public struct Version: Equatable, Comparable {
public let major: Int
public let minor: Int
public let patch: Int

public var stringValue: String {
if patch > 0 {
return "\(major).\(minor).\(patch)"
} else {
return "\(major).\(minor)"
}
}

public init(major: Int, minor: Int = 0, patch: Int = 0) {
self.major = major
self.minor = minor
self.patch = patch
}

public init(rawValue: String) throws {
func parseNumber(_ string: String) throws -> Int {
guard let number = Int(string) else {
throw ConfigurationError.unknownConfiguration
}
return number
}

let parts = rawValue.components(separatedBy: ".")
let count = parts.count
switch count {
case 0:
throw ConfigurationError.unknownConfiguration
case 1:
major = try parseNumber(parts[0])
minor = 0
patch = 0
case 2:
major = try parseNumber(parts[0])
minor = try parseNumber(parts[1])
patch = 0
default:
major = try parseNumber(parts[0])
minor = try parseNumber(parts[1])
patch = try parseNumber(parts[2])
}
}

fileprivate init(value: Any) throws {
if let version = value as? String {
try self.init(rawValue: version)
} else {
try self.init(rawValue: String(describing: value))
}
}

public static func < (lhs: Version, rhs: Version) -> Bool {
if lhs.major != rhs.major {
return lhs.major < rhs.major
} else if lhs.minor != rhs.minor {
return lhs.minor < rhs.minor
} else {
return lhs.patch < rhs.patch
}
}
}

private(set) var iOSDeploymentTarget = Version(major: 7)
private(set) var macOSDeploymentTarget = Version(major: 10, minor: 9)
private(set) var watchOSDeploymentTarget = Version(major: 1)
private(set) var tvOSDeploymentTarget = Version(major: 9)

private(set) var severityConfiguration = SeverityConfiguration(.warning)

public var consoleDescription: String {
return severityConfiguration.consoleDescription +
", iOS_deployment_target: \(iOSDeploymentTarget.stringValue)" +
", macOS_deployment_target: \(macOSDeploymentTarget.stringValue)" +
", watchOS_deployment_target: \(watchOSDeploymentTarget.stringValue)" +
", tvOS_deployment_target: \(tvOSDeploymentTarget.stringValue)"
}

public init() {}

public mutating func apply(configuration: Any) throws {
guard let configuration = configuration as? [String: Any] else {
throw ConfigurationError.unknownConfiguration
}
for (key, value) in configuration {
switch (key, value) {
case ("severity", let severityString as String):
try severityConfiguration.apply(configuration: severityString)
case ("iOS_deployment_target", let deploymentTarget):
self.iOSDeploymentTarget = try Version(value: deploymentTarget)
case ("macOS_deployment_target", let deploymentTarget):
self.macOSDeploymentTarget = try Version(value: deploymentTarget)
case ("watchOS_deployment_target", let deploymentTarget):
self.watchOSDeploymentTarget = try Version(value: deploymentTarget)
case ("tvOS_deployment_target", let deploymentTarget):
self.tvOSDeploymentTarget = try Version(value: deploymentTarget)
default:
throw ConfigurationError.unknownConfiguration
}
}
}
}
Loading

0 comments on commit 7c38722

Please sign in to comment.