diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c71144eb..d109fa83bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,6 +130,12 @@ [Martin Redington](https://github.com/mildm8nnered) [#5711](https://github.com/realm/SwiftLint/issues/5711) +* Fixes `file_name` rule to match fully-qualified names of nested types. + Additionally adds a `require_fully_qualified_names` boolean option to enforce + that file names match nested types only using their fully-qualified name. + [fraioli](https://github.com/fraioli) + [#5840](https://github.com/realm/SwiftLint/issues/5840) + ## 0.57.0: Squeaky Clean Cycle #### Breaking diff --git a/Source/SwiftLintBuiltInRules/Rules/Idiomatic/FileNameRule.swift b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/FileNameRule.swift index d3554619eb..fa8999122d 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Idiomatic/FileNameRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/FileNameRule.swift @@ -35,7 +35,9 @@ struct FileNameRule: OptInRule, SourceKitFreeRule { } // Process nested type separator - let allDeclaredTypeNames = TypeNameCollectingVisitor(viewMode: .sourceAccurate) + let allDeclaredTypeNames = TypeNameCollectingVisitor( + requireFullyQualifiedNames: configuration.requireFullyQualifiedNames + ) .walk(tree: file.syntaxTree, handler: \.names) .map { $0.replacingOccurrences(of: ".", with: configuration.nestedTypeSeparator) @@ -56,33 +58,96 @@ struct FileNameRule: OptInRule, SourceKitFreeRule { } private class TypeNameCollectingVisitor: SyntaxVisitor { + /// All of a visited node's ancestor type names if that node is nested, starting with the furthest + /// ancestor and ending with the direct parent + private var ancestorNames = Stack() + + /// All of the type names found in the file private(set) var names: Set = [] - override func visitPost(_ node: ClassDeclSyntax) { - names.insert(node.name.text) + /// If true, nested types are only allowed in the file name when used by their fully-qualified name + /// (e.g. `My.Nested.Type` and not just `Type`) + private let requireFullyQualifiedNames: Bool + + init(requireFullyQualifiedNames: Bool) { + self.requireFullyQualifiedNames = requireFullyQualifiedNames + super.init(viewMode: .sourceAccurate) + } + + /// Calls `visit(name:)` using the name of the provided node + private func visit(node: some NamedDeclSyntax) -> SyntaxVisitorContinueKind { + visit(name: node.name.trimmedDescription) + } + + /// Visits a node with the provided name, storing that name as an ancestor type name to prepend to + /// any children to form their fully-qualified names + private func visit(name: String) -> SyntaxVisitorContinueKind { + let fullyQualifiedName = (ancestorNames + [name]).joined(separator: ".") + names.insert(fullyQualifiedName) + + // If the options don't require only fully-qualified names, then we will allow this node's + // name to be used by itself + if !requireFullyQualifiedNames { + names.insert(name) + } + + ancestorNames.push(name) + return .visitChildren + } + + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + visit(node: node) + } + + override func visitPost(_: ClassDeclSyntax) { + ancestorNames.pop() + } + + override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + visit(node: node) + } + + override func visitPost(_: ActorDeclSyntax) { + ancestorNames.pop() + } + + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + visit(node: node) + } + + override func visitPost(_: StructDeclSyntax) { + ancestorNames.pop() + } + + override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind { + visit(node: node) + } + + override func visitPost(_: TypeAliasDeclSyntax) { + ancestorNames.pop() } - override func visitPost(_ node: ActorDeclSyntax) { - names.insert(node.name.text) + override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + visit(node: node) } - override func visitPost(_ node: StructDeclSyntax) { - names.insert(node.name.text) + override func visitPost(_: EnumDeclSyntax) { + ancestorNames.pop() } - override func visitPost(_ node: TypeAliasDeclSyntax) { - names.insert(node.name.text) + override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { + visit(node: node) } - override func visitPost(_ node: EnumDeclSyntax) { - names.insert(node.name.text) + override func visitPost(_: ProtocolDeclSyntax) { + ancestorNames.pop() } - override func visitPost(_ node: ProtocolDeclSyntax) { - names.insert(node.name.text) + override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { + visit(name: node.extendedType.trimmedDescription) } - override func visitPost(_ node: ExtensionDeclSyntax) { - names.insert(node.extendedType.trimmedDescription) + override func visitPost(_: ExtensionDeclSyntax) { + ancestorNames.pop() } } diff --git a/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/FileNameConfiguration.swift b/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/FileNameConfiguration.swift index 08d414eacd..7aa8be61b5 100644 --- a/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/FileNameConfiguration.swift +++ b/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/FileNameConfiguration.swift @@ -14,4 +14,6 @@ struct FileNameConfiguration: SeverityBasedRuleConfiguration { private(set) var suffixPattern = "\\+.*" @ConfigurationElement(key: "nested_type_separator") private(set) var nestedTypeSeparator = "." + @ConfigurationElement(key: "require_fully_qualified_names") + private(set) var requireFullyQualifiedNames = false } diff --git a/Tests/IntegrationTests/default_rule_configurations.yml b/Tests/IntegrationTests/default_rule_configurations.yml index bbb19d493b..772b29252c 100644 --- a/Tests/IntegrationTests/default_rule_configurations.yml +++ b/Tests/IntegrationTests/default_rule_configurations.yml @@ -177,6 +177,7 @@ file_name: prefix_pattern: "" suffix_pattern: "\+.*" nested_type_separator: "." + require_fully_qualified_names: false file_name_no_space: severity: warning excluded: [] diff --git a/Tests/SwiftLintFrameworkTests/FileNameRuleTests.swift b/Tests/SwiftLintFrameworkTests/FileNameRuleTests.swift index 30f3167966..4e3a2923fa 100644 --- a/Tests/SwiftLintFrameworkTests/FileNameRuleTests.swift +++ b/Tests/SwiftLintFrameworkTests/FileNameRuleTests.swift @@ -5,26 +5,33 @@ private let fixturesDirectory = "\(TestResources.path)/FileNameRuleFixtures" final class FileNameRuleTests: SwiftLintTestCase { private func validate(fileName: String, - excludedOverride: [String]? = nil, + excluded: [String]? = nil, prefixPattern: String? = nil, suffixPattern: String? = nil, - nestedTypeSeparator: String? = nil) throws -> [StyleViolation] { + nestedTypeSeparator: String? = nil, + requireFullyQualifiedNames: Bool = false) throws -> [StyleViolation] { let file = SwiftLintFile(path: fixturesDirectory.stringByAppendingPathComponent(fileName))! - let rule: FileNameRule - if let excluded = excludedOverride { - rule = try FileNameRule(configuration: ["excluded": excluded]) - } else if let prefixPattern, let suffixPattern { - rule = try FileNameRule(configuration: ["prefix_pattern": prefixPattern, "suffix_pattern": suffixPattern]) - } else if let prefixPattern { - rule = try FileNameRule(configuration: ["prefix_pattern": prefixPattern]) - } else if let suffixPattern { - rule = try FileNameRule(configuration: ["suffix_pattern": suffixPattern]) - } else if let nestedTypeSeparator { - rule = try FileNameRule(configuration: ["nested_type_separator": nestedTypeSeparator]) - } else { - rule = FileNameRule() + + var configuration = [String: Any]() + + if let excluded { + configuration["excluded"] = excluded + } + if let prefixPattern { + configuration["prefix_pattern"] = prefixPattern + } + if let suffixPattern { + configuration["suffix_pattern"] = suffixPattern + } + if let nestedTypeSeparator { + configuration["nested_type_separator"] = nestedTypeSeparator + } + if requireFullyQualifiedNames { + configuration["require_fully_qualified_names"] = requireFullyQualifiedNames } + let rule = try FileNameRule(configuration: configuration) + return rule.validate(file: file) } @@ -52,14 +59,30 @@ final class FileNameRuleTests: SwiftLintTestCase { XCTAssert(try validate(fileName: "Notification.Name+Extension.swift").isEmpty) } + func testNestedTypeDoesntTrigger() { + XCTAssert(try validate(fileName: "Nested.MyType.swift").isEmpty) + } + + func testMultipleLevelsDeeplyNestedTypeDoesntTrigger() { + XCTAssert(try validate(fileName: "Multiple.Levels.Deeply.Nested.MyType.swift").isEmpty) + } + + func testNestedTypeNotFullyQualifiedDoesntTrigger() { + XCTAssert(try validate(fileName: "MyType.swift").isEmpty) + } + + func testNestedTypeNotFullyQualifiedDoesTriggerWithOverride() { + XCTAssert(try validate(fileName: "MyType.swift", requireFullyQualifiedNames: true).isNotEmpty) + } + func testNestedTypeSeparatorDoesntTrigger() { XCTAssert(try validate(fileName: "NotificationName+Extension.swift", nestedTypeSeparator: "").isEmpty) XCTAssert(try validate(fileName: "Notification__Name+Extension.swift", nestedTypeSeparator: "__").isEmpty) } func testWrongNestedTypeSeparatorDoesTrigger() { - XCTAssert(try !validate(fileName: "Notification__Name+Extension.swift", nestedTypeSeparator: ".").isEmpty) - XCTAssert(try !validate(fileName: "NotificationName+Extension.swift", nestedTypeSeparator: "__").isEmpty) + XCTAssert(try validate(fileName: "Notification__Name+Extension.swift", nestedTypeSeparator: ".").isNotEmpty) + XCTAssert(try validate(fileName: "NotificationName+Extension.swift", nestedTypeSeparator: "__").isNotEmpty) } func testMisspelledNameDoesTrigger() { @@ -67,11 +90,11 @@ final class FileNameRuleTests: SwiftLintTestCase { } func testMisspelledNameDoesntTriggerWithOverride() { - XCTAssert(try validate(fileName: "MyStructf.swift", excludedOverride: ["MyStructf.swift"]).isEmpty) + XCTAssert(try validate(fileName: "MyStructf.swift", excluded: ["MyStructf.swift"]).isEmpty) } func testMainDoesTriggerWithoutOverride() { - XCTAssertEqual(try validate(fileName: "main.swift", excludedOverride: []).count, 1) + XCTAssertEqual(try validate(fileName: "main.swift", excluded: []).count, 1) } func testCustomSuffixPattern() { diff --git a/Tests/SwiftLintFrameworkTests/Resources/FileNameRuleFixtures/Multiple.Levels.Deeply.Nested.MyType.swift b/Tests/SwiftLintFrameworkTests/Resources/FileNameRuleFixtures/Multiple.Levels.Deeply.Nested.MyType.swift new file mode 100644 index 0000000000..46bb755a47 --- /dev/null +++ b/Tests/SwiftLintFrameworkTests/Resources/FileNameRuleFixtures/Multiple.Levels.Deeply.Nested.MyType.swift @@ -0,0 +1,9 @@ +extension Multiple { + enum Levels { + class Deeply { + struct Nested { + actor MyType {} + } + } + } +} diff --git a/Tests/SwiftLintFrameworkTests/Resources/FileNameRuleFixtures/MyType.swift b/Tests/SwiftLintFrameworkTests/Resources/FileNameRuleFixtures/MyType.swift new file mode 100644 index 0000000000..7c72b6af9d --- /dev/null +++ b/Tests/SwiftLintFrameworkTests/Resources/FileNameRuleFixtures/MyType.swift @@ -0,0 +1,3 @@ +enum Nested { + struct MyType {} +} diff --git a/Tests/SwiftLintFrameworkTests/Resources/FileNameRuleFixtures/Nested.MyType.swift b/Tests/SwiftLintFrameworkTests/Resources/FileNameRuleFixtures/Nested.MyType.swift new file mode 100644 index 0000000000..a57866f72a --- /dev/null +++ b/Tests/SwiftLintFrameworkTests/Resources/FileNameRuleFixtures/Nested.MyType.swift @@ -0,0 +1,4 @@ +enum Nested { + struct MyType { + } +}