diff --git a/Sources/SPMGraphConfigSetup/Resources/SPMGraphConfig.txt b/Sources/SPMGraphConfigSetup/Resources/SPMGraphConfig.txt index 4c1904f..e267ebc 100644 --- a/Sources/SPMGraphConfigSetup/Resources/SPMGraphConfig.txt +++ b/Sources/SPMGraphConfigSetup/Resources/SPMGraphConfig.txt @@ -67,9 +67,12 @@ public extension Array where Element == SPMGraphConfig.Lint.Rule { .abcFeatureModuleShouldNotDependOnThirdParties, .ruleThatChecksTheSourceFilesContent, // Example of how to use built in rules with your own conditions. - // Note that `.liveModuleLiveDependency()` and `.baseOrInterfaceModuleLiveDependency()` are + // Note that `.unusedDependencies()`, `.liveModuleLiveDependency()` and `.baseOrInterfaceModuleLiveDependency()` are // enabled by default as part of the `.default` ones, so consider removing `.default` and picking the // ones you wish to use! + .unusedDependencies( + excludedDependencies: ["DependencyToExclude"] // Exclude specific dependencies + ), .liveModuleLiveDependency( isLiveModule: { $0.name.hasSuffix("Implementation") diff --git a/Sources/SPMGraphDescriptionInterface/SPMGraphConfigInterface.swift b/Sources/SPMGraphDescriptionInterface/SPMGraphConfigInterface.swift index 0198d48..dc99d2b 100644 --- a/Sources/SPMGraphDescriptionInterface/SPMGraphConfigInterface.swift +++ b/Sources/SPMGraphDescriptionInterface/SPMGraphConfigInterface.swift @@ -102,11 +102,10 @@ typealias Validate = (Package, _ excludedSuffixes: [String]) -> [LocalizedError] public extension Array where Element == SPMGraphConfig.Lint.Rule { /// The default lint rules for users of the spmgraph lint functionality. /// - /// - note: **Most default rules allow for customization**, i.e. `liveModuleLiveDependency` can be used - /// directly when setting up your `SPMGraphConfig.swift` and with a custom implementation of both the `isLiveModule` and - /// the `excludedDependencies` parameters. + /// - note: **Most default rules allow for customization**, i.e. `liveModuleLiveDependency` and `unusedDependencies` can be used + /// directly when setting up your `SPMGraphConfig.swift` with custom parameters such as `excludedDependencies`. static let `default`: [SPMGraphConfig.Lint.Rule] = [ - .unusedDependencies, + .unusedDependencies(), .liveModuleLiveDependency(), .baseOrInterfaceModuleLiveDependency(), ] @@ -212,61 +211,69 @@ public extension SPMGraphConfig.Lint.Rule { /// - note: For `@_exported` usages, there will be an error in case only the exported module is used. /// For example, module Networking exports module NetworkingHelpers, if only NetworkingHelpers is used by a target there will be /// a lint error, while if both Networking and NetworkingHelpers are used there will be no error. - static let unusedDependencies = Self( - id: "unusedDependencies", - name: "Unused linked dependencies", - abstract: """ - To keep the project clean and avoid long compile times, a Module should not have any unused dependencies. - - - Note: It does blindly expects the target to match the product name, and doesn't yet consider - the multiple targets that compose a product (open improvement). - - - Note: For `@_exported` usages, there will be an error in case only the exported module is used. - For example, module Networking exports module NetworkingHelpers, if only NetworkingHelpers is used by a target - there will be a lint error, while if both Networking and NetworkingHelpers are used there will be no error. - """, - validate: { package, excludedSuffixes in - let errors: [SPMGraphConfig.Lint.Error] = package.modules - .filter { !$0.containsOneOf(suffixes: excludedSuffixes) && !$0.isFeature } - .sorted() - .compactMap { module in - let dependencies = module - .dependenciesFilteringOutLiveInUITestSupport - .filter { dependency in - let isExcluded = dependency.containsOneOf(suffixes: excludedSuffixes) - return !isExcluded && dependency.shouldBeImported - } - let swiftFiles = try? findSwiftFiles(in: module.path.pathString) - - return dependencies.compactMap { dependency in - let filePaths = swiftFiles ?? [] - var isDependencyUsed = false - for filePath in filePaths { - let fileContent = try? String(contentsOfFile: filePath, encoding: .utf8) - let regexPattern = - "import (enum |struct |class )?(\\b\(NSRegularExpression.escapedPattern(for: dependency.name))\\b)" - if let regex = try? NSRegularExpression(pattern: regexPattern, options: []) { - let range = NSRange(location: 0, length: fileContent?.utf16.count ?? 0) - let match = regex.firstMatch(in: fileContent ?? "", options: [], range: range) - if match != nil { - isDependencyUsed = true - break + /// + /// - Parameters: + /// - excludedDependencies: A list of dependency names that should be excluded from unused dependency checks (e.g., umbrella dependencies). + static func unusedDependencies( + excludedDependencies: [String] = [] + ) -> Self { + Self( + id: "unusedDependencies", + name: "Unused linked dependencies", + abstract: """ + To keep the project clean and avoid long compile times, a Module should not have any unused dependencies. + + - Note: It does blindly expects the target to match the product name, and doesn't yet consider + the multiple targets that compose a product (open improvement). + + - Note: For `@_exported` usages, there will be an error in case only the exported module is used. + For example, module Networking exports module NetworkingHelpers, if only NetworkingHelpers is used by a target + there will be a lint error, while if both Networking and NetworkingHelpers are used there will be no error. + """, + validate: { package, excludedSuffixes in + let errors: [SPMGraphConfig.Lint.Error] = package.modules + .filter { !$0.containsOneOf(suffixes: excludedSuffixes) && !$0.isFeature } + .sorted() + .compactMap { module in + let dependencies = module + .dependenciesFilteringOutLiveInUITestSupport + .filter { dependency in + let isExcluded = dependency.containsOneOf(suffixes: excludedSuffixes) + let isExcludedDependency = excludedDependencies.contains(dependency.name) + return !isExcluded && !isExcludedDependency && dependency.shouldBeImported + } + let swiftFiles = try? findSwiftFiles(in: module.path.pathString) + + return dependencies.compactMap { dependency in + let filePaths = swiftFiles ?? [] + var isDependencyUsed = false + for filePath in filePaths { + let fileContent = try? String(contentsOfFile: filePath, encoding: .utf8) + let regexPattern = + "import (enum |struct |class )?(\\b\(NSRegularExpression.escapedPattern(for: dependency.name))\\b)" + if let regex = try? NSRegularExpression(pattern: regexPattern, options: []) { + let range = NSRange(location: 0, length: fileContent?.utf16.count ?? 0) + let match = regex.firstMatch(in: fileContent ?? "", options: [], range: range) + if match != nil { + isDependencyUsed = true + break + } } } - } - return isDependencyUsed - ? nil - : SPMGraphConfig.Lint.Error.unusedDependencies( - moduleName: module.name, - dependencyName: dependency.name - ) + return isDependencyUsed + ? nil + : SPMGraphConfig.Lint.Error.unusedDependencies( + moduleName: module.name, + dependencyName: dependency.name + ) + } } - } - .flatMap { $0 } - return errors - } - ) + .flatMap { $0 } + return errors + } + ) + } } private extension SPMGraphConfig.Lint.Rule { diff --git a/Tests/SPMGraphDescriptionInterfaceTests/SPMGraphConfigTests.swift b/Tests/SPMGraphDescriptionInterfaceTests/SPMGraphConfigTests.swift index 62d1c5c..75a2046 100644 --- a/Tests/SPMGraphDescriptionInterfaceTests/SPMGraphConfigTests.swift +++ b/Tests/SPMGraphDescriptionInterfaceTests/SPMGraphConfigTests.swift @@ -188,7 +188,7 @@ struct SPMGraphConfigTests { @Test("It has the correct properties") func testRuleProperties() { // GIVEN - let rule = SPMGraphConfig.Lint.Rule.unusedDependencies + let rule = SPMGraphConfig.Lint.Rule.unusedDependencies() // THEN #expect(rule.id == "unusedDependencies") @@ -198,7 +198,7 @@ struct SPMGraphConfigTests { To keep the project clean and avoid long compile times, a Module should not have any unused dependencies. - Note: It does blindly expects the target to match the product name, and doesn't yet consider - the multiple targets that compose a product (open improvement). + the multiple targets that compose a product (open improvement). - Note: For `@_exported` usages, there will be an error in case only the exported module is used. For example, module Networking exports module NetworkingHelpers, if only NetworkingHelpers is used by a target @@ -210,7 +210,7 @@ struct SPMGraphConfigTests { @Test("Validate detects unused dependencies") func testValidateDetectsUnusedDependencies() async throws { // GIVEN - let rule = SPMGraphConfig.Lint.Rule.unusedDependencies + let rule = SPMGraphConfig.Lint.Rule.unusedDependencies() // WHEN let package = try await loadFixturePackage() @@ -233,7 +233,7 @@ struct SPMGraphConfigTests { @Test("Validate with excluded suffixes ignores matching modules") func testValidateWithExcludedSuffixes() async throws { // GIVEN - let rule = SPMGraphConfig.Lint.Rule.unusedDependencies + let rule = SPMGraphConfig.Lint.Rule.unusedDependencies() // WHEN - Exclude modules with "WithUnusedDep" suffix let package = try await loadFixturePackage() @@ -242,6 +242,21 @@ struct SPMGraphConfigTests { // THEN #expect(errors.isEmpty, "BaseModule should be ignored") } + + @Test("Validate with excluded dependencies ignores specific dependencies") + func testValidateWithExcludedDependencies() async throws { + // GIVEN + let rule = SPMGraphConfig.Lint.Rule.unusedDependencies( + excludedDependencies: ["BaseModule"] + ) + + // WHEN + let package = try await loadFixturePackage() + let errors = rule.validate(package, []) + + // THEN + #expect(errors.isEmpty, "BaseModule should be excluded from unused dependency checks") + } } @Suite("Default rules configuration")