diff --git a/CHANGELOG.md b/CHANGELOG.md index 820df3e6a0..f5b73ad74f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ `.last(where: { /* ... */ })` is more efficient. [Marcelo Fabri](https://github.com/marcelofabri) +* Add `unused_control_flow_label` rule to validate that control flow labels are + used. + [Marcelo Fabri](https://github.com/marcelofabri) + [#2227](https://github.com/realm/SwiftLint/issues/2227) + #### Bug Fixes * Fix false positives on `first_where` rule when calling `filter` without a diff --git a/Rules.md b/Rules.md index 12238eb92e..06dffb7167 100644 --- a/Rules.md +++ b/Rules.md @@ -145,6 +145,7 @@ * [Unneeded Parentheses in Closure Argument](#unneeded-parentheses-in-closure-argument) * [Untyped Error in Catch](#untyped-error-in-catch) * [Unused Closure Parameter](#unused-closure-parameter) +* [Unused Control Flow Label](#unused-control-flow-label) * [Unused Enumerated](#unused-enumerated) * [Unused Import](#unused-import) * [Unused Optional Binding](#unused-optional-binding) @@ -21539,6 +21540,96 @@ func foo () { +## Unused Control Flow Label + +Identifier | Enabled by default | Supports autocorrection | Kind | Analyzer | Minimum Swift Compiler Version +--- | --- | --- | --- | --- | --- +`unused_control_flow_label` | Enabled | Yes | lint | No | 3.0.0 + +Unused control flow label should be removed. + +### Examples + +
+Non Triggering Examples + +```swift +loop: while true { break loop } +``` + +```swift +loop: while true { continue loop } +``` + +```swift +loop: + while true { break loop } +``` + +```swift +while true { break } +``` + +```swift +loop: for x in array { break loop } +``` + +```swift +label: switch number { +case 1: print("1") +case 2: print("2") +default: break label +} +``` + +```swift +loop: repeat { + if x == 10 { + break loop + } +} while true +``` + +
+
+Triggering Examples + +```swift +↓loop: while true { break } +``` + +```swift +↓loop: while true { break loop1 } +``` + +```swift +↓loop: while true { break outerLoop } +``` + +```swift +↓loop: for x in array { break } +``` + +```swift +↓label: switch number { +case 1: print("1") +case 2: print("2") +default: break +} +``` + +```swift +↓loop: repeat { + if x == 10 { + break + } +} while true +``` + +
+ + + ## Unused Enumerated Identifier | Enabled by default | Supports autocorrection | Kind | Analyzer | Minimum Swift Compiler Version diff --git a/Source/SwiftLintFramework/Models/MasterRuleList.swift b/Source/SwiftLintFramework/Models/MasterRuleList.swift index 5c64834e7f..1056af53cd 100644 --- a/Source/SwiftLintFramework/Models/MasterRuleList.swift +++ b/Source/SwiftLintFramework/Models/MasterRuleList.swift @@ -146,6 +146,7 @@ public let masterRuleList = RuleList(rules: [ UnneededParenthesesInClosureArgumentRule.self, UntypedErrorInCatchRule.self, UnusedClosureParameterRule.self, + UnusedControlFlowLabelRule.self, UnusedEnumeratedRule.self, UnusedImportRule.self, UnusedOptionalBindingRule.self, diff --git a/Source/SwiftLintFramework/Rules/Lint/UnusedControlFlowLabelRule.swift b/Source/SwiftLintFramework/Rules/Lint/UnusedControlFlowLabelRule.swift new file mode 100644 index 0000000000..75fb416913 --- /dev/null +++ b/Source/SwiftLintFramework/Rules/Lint/UnusedControlFlowLabelRule.swift @@ -0,0 +1,173 @@ +import Foundation +import SourceKittenFramework + +public struct UnusedControlFlowLabelRule: ASTRule, ConfigurationProviderRule, AutomaticTestableRule, CorrectableRule { + public var configuration = SeverityConfiguration(.warning) + + public init() {} + + public static let description = RuleDescription( + identifier: "unused_control_flow_label", + name: "Unused Control Flow Label", + description: "Unused control flow label should be removed.", + kind: .lint, + nonTriggeringExamples: [ + "loop: while true { break loop }", + "loop: while true { continue loop }", + "loop:\n while true { break loop }", + "while true { break }", + "loop: for x in array { break loop }", + """ + label: switch number { + case 1: print("1") + case 2: print("2") + default: break label + } + """, + """ + loop: repeat { + if x == 10 { + break loop + } + } while true + """ + ], + triggeringExamples: [ + "↓loop: while true { break }", + "↓loop: while true { break loop1 }", + "↓loop: while true { break outerLoop }", + "↓loop: for x in array { break }", + """ + ↓label: switch number { + case 1: print("1") + case 2: print("2") + default: break + } + """, + """ + ↓loop: repeat { + if x == 10 { + break + } + } while true + """ + ], + corrections: [ + "↓loop: while true { break }": "while true { break }", + "↓loop: while true { break loop1 }": "while true { break loop1 }", + "↓loop: while true { break outerLoop }": "while true { break outerLoop }", + "↓loop: for x in array { break }": "for x in array { break }", + """ + ↓label: switch number { + case 1: print("1") + case 2: print("2") + default: break + } + """: """ + switch number { + case 1: print("1") + case 2: print("2") + default: break + } + """, + """ + ↓loop: repeat { + if x == 10 { + break + } + } while true + """: """ + repeat { + if x == 10 { + break + } + } while true + """ + ] + ) + + private static let kinds: Set = [.if, .for, .forEach, .while, .repeatWhile, .switch] + + public func validate(file: File, kind: StatementKind, + dictionary: [String: SourceKitRepresentable]) -> [StyleViolation] { + return self.violationRanges(in: file, kind: kind, dictionary: dictionary).map { range in + StyleViolation(ruleDescription: type(of: self).description, + severity: configuration.severity, + location: Location(file: file, characterOffset: range.location)) + } + } + + public func correct(file: File) -> [Correction] { + let violatingRanges = file.ruleEnabled(violatingRanges: violationRanges(in: file), for: self) + guard !violatingRanges.isEmpty else { return [] } + + let description = type(of: self).description + var corrections = [Correction]() + var contents = file.contents + for range in violatingRanges { + var rangeToRemove = range + let contentsNSString = contents.bridge() + if let byteRange = contentsNSString.NSRangeToByteRange(start: range.location, length: range.length), + let nextToken = file.syntaxMap.tokens.first(where: { $0.offset > byteRange.location }), + let nextTokenLocation = contentsNSString.byteRangeToNSRange(start: nextToken.offset, length: 0) { + rangeToRemove.length = nextTokenLocation.location - range.location + } + + contents = contentsNSString.replacingCharacters(in: rangeToRemove, with: "") + let location = Location(file: file, characterOffset: range.location) + corrections.append(Correction(ruleDescription: description, location: location)) + } + + file.write(contents) + return corrections + } + + private func violationRanges(in file: File, kind: StatementKind, + dictionary: [String: SourceKitRepresentable]) -> [NSRange] { + guard type(of: self).kinds.contains(kind), + let offset = dictionary.offset, let length = dictionary.length, + case let byteRange = NSRange(location: offset, length: length), + case let tokens = file.syntaxMap.tokens(inByteRange: byteRange), + let firstToken = tokens.first, + SyntaxKind(rawValue: firstToken.type) == .identifier, + case let contents = file.contents.bridge(), + let tokenContent = contents.substring(with: firstToken), + let range = contents.byteRangeToNSRange(start: offset, length: length) else { + return [] + } + + let pattern = "(?:break|continue)\\s+\(tokenContent)\\b" + guard file.match(pattern: pattern, with: [.keyword, .identifier], range: range).isEmpty, + let violationRange = contents.byteRangeToNSRange(start: firstToken.offset, + length: firstToken.length) else { + return [] + } + + return [violationRange] + } + + private func violationRanges(in file: File, dictionary: [String: SourceKitRepresentable]) -> [NSRange] { + let ranges = dictionary.substructure.flatMap { subDict -> [NSRange] in + var ranges = violationRanges(in: file, dictionary: subDict) + if let kind = subDict.kind.flatMap(StatementKind.init(rawValue:)) { + ranges += violationRanges(in: file, kind: kind, dictionary: subDict) + } + + return ranges + } + + return ranges.unique + } + + private func violationRanges(in file: File) -> [NSRange] { + return violationRanges(in: file, dictionary: file.structure.dictionary).sorted { lhs, rhs in + lhs.location > rhs.location + } + } +} + +private extension NSString { + func substring(with token: SyntaxToken) -> String? { + return substringWithByteRange(start: token.offset, length: token.length) + } +} diff --git a/SwiftLint.xcodeproj/project.pbxproj b/SwiftLint.xcodeproj/project.pbxproj index 8a0040679c..449c669322 100644 --- a/SwiftLint.xcodeproj/project.pbxproj +++ b/SwiftLint.xcodeproj/project.pbxproj @@ -291,6 +291,7 @@ D4D1B9BB1EAC2C910028BE6A /* AccessControlLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D1B9B91EAC2C870028BE6A /* AccessControlLevel.swift */; }; D4D383852145F550000235BD /* StaticOperatorRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D383842145F550000235BD /* StaticOperatorRule.swift */; }; D4D5A5FF1E1F3A1C00D15E0C /* ShorthandOperatorRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D5A5FE1E1F3A1C00D15E0C /* ShorthandOperatorRule.swift */; }; + D4D7320D21E15ED4001C07D9 /* UnusedControlFlowLabelRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D7320C21E15ED4001C07D9 /* UnusedControlFlowLabelRule.swift */; }; D4DA1DF41E17511D0037413D /* CompilerProtocolInitRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4DA1DF31E17511D0037413D /* CompilerProtocolInitRule.swift */; }; D4DA1DFA1E18D6200037413D /* LargeTupleRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4DA1DF91E18D6200037413D /* LargeTupleRule.swift */; }; D4DA1DFC1E19CD300037413D /* GenerateDocsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4DA1DFB1E19CD300037413D /* GenerateDocsCommand.swift */; }; @@ -745,6 +746,7 @@ D4D1B9B91EAC2C870028BE6A /* AccessControlLevel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccessControlLevel.swift; sourceTree = ""; }; D4D383842145F550000235BD /* StaticOperatorRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticOperatorRule.swift; sourceTree = ""; }; D4D5A5FE1E1F3A1C00D15E0C /* ShorthandOperatorRule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShorthandOperatorRule.swift; sourceTree = ""; }; + D4D7320C21E15ED4001C07D9 /* UnusedControlFlowLabelRule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnusedControlFlowLabelRule.swift; sourceTree = ""; }; D4DA1DF31E17511D0037413D /* CompilerProtocolInitRule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompilerProtocolInitRule.swift; sourceTree = ""; }; D4DA1DF91E18D6200037413D /* LargeTupleRule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LargeTupleRule.swift; sourceTree = ""; }; D4DA1DFB1E19CD300037413D /* GenerateDocsCommand.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GenerateDocsCommand.swift; sourceTree = ""; }; @@ -1033,6 +1035,7 @@ D40E041B1F46E3B30043BC4E /* SuperfluousDisableCommandRule.swift */, E88DEA811B0990A700A66CB0 /* TodoRule.swift */, D40AD0891E032F9700F48C30 /* UnusedClosureParameterRule.swift */, + D4D7320C21E15ED4001C07D9 /* UnusedControlFlowLabelRule.swift */, 8F715B82213B528B00427BD9 /* UnusedImportRule.swift */, 8F6B3153213CDCD100858E44 /* UnusedPrivateDeclarationRule.swift */, D442541E1DB87C3D00492EA4 /* ValidIBInspectableRule.swift */, @@ -1920,6 +1923,7 @@ 62DADC481FFF0423002B6319 /* PrefixedTopLevelConstantRule.swift in Sources */, D4130D991E16CC1300242361 /* TypeNameRuleExamples.swift in Sources */, 24E17F721B14BB3F008195BE /* File+Cache.swift in Sources */, + D4D7320D21E15ED4001C07D9 /* UnusedControlFlowLabelRule.swift in Sources */, 6C1D763221A4E69600DEF783 /* Request+DisableSourceKit.swift in Sources */, 47ACC8981E7DC74E0088EEB2 /* ImplicitlyUnwrappedOptionalConfiguration.swift in Sources */, 787CDE39208E7D41005F3D2F /* SwitchCaseAlignmentConfiguration.swift in Sources */, diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index a985b17be4..0b1131565a 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -1253,6 +1253,12 @@ extension UnusedClosureParameterRuleTests { ] } +extension UnusedControlFlowLabelRuleTests { + static var allTests: [(String, (UnusedControlFlowLabelRuleTests) -> () throws -> Void)] = [ + ("testWithDefaultConfiguration", testWithDefaultConfiguration) + ] +} + extension UnusedEnumeratedRuleTests { static var allTests: [(String, (UnusedEnumeratedRuleTests) -> () throws -> Void)] = [ ("testWithDefaultConfiguration", testWithDefaultConfiguration) @@ -1548,6 +1554,7 @@ XCTMain([ testCase(UnneededParenthesesInClosureArgumentRuleTests.allTests), testCase(UntypedErrorInCatchRuleTests.allTests), testCase(UnusedClosureParameterRuleTests.allTests), + testCase(UnusedControlFlowLabelRuleTests.allTests), testCase(UnusedEnumeratedRuleTests.allTests), testCase(UnusedImportRuleTests.allTests), testCase(UnusedOptionalBindingRuleTests.allTests), diff --git a/Tests/SwiftLintFrameworkTests/AutomaticRuleTests.generated.swift b/Tests/SwiftLintFrameworkTests/AutomaticRuleTests.generated.swift index 06bb0b0394..b6912a34ed 100644 --- a/Tests/SwiftLintFrameworkTests/AutomaticRuleTests.generated.swift +++ b/Tests/SwiftLintFrameworkTests/AutomaticRuleTests.generated.swift @@ -648,6 +648,12 @@ class UnusedClosureParameterRuleTests: XCTestCase { } } +class UnusedControlFlowLabelRuleTests: XCTestCase { + func testWithDefaultConfiguration() { + verifyRule(UnusedControlFlowLabelRule.description) + } +} + class UnusedEnumeratedRuleTests: XCTestCase { func testWithDefaultConfiguration() { verifyRule(UnusedEnumeratedRule.description)