-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2591 from marcelofabri/mf-deployment-target-rule
Add `deployment_target` rule
- Loading branch information
Showing
9 changed files
with
488 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
141 changes: 141 additions & 0 deletions
141
Source/SwiftLintFramework/Rules/Lint/DeploymentTargetRule.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
] | ||
} | ||
} |
106 changes: 106 additions & 0 deletions
106
Source/SwiftLintFramework/Rules/RuleConfigurations/DeploymentTargetConfiguration.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} | ||
} | ||
} |
Oops, something went wrong.