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)