From e451f8bb3050f9839620a51e37c5a9dd009ebed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danny=20M=C3=B6sch?= Date: Sat, 15 Nov 2025 15:43:11 +0100 Subject: [PATCH 1/5] Add test cases --- .swiftlint.yml | 1 + Package.swift | 3 +- .../ConfigHierarchyPathResolutionTests.swift | 323 ++++++++++++++++++ .../Sources/CoreFile.swift | 4 + .../Sources/Generated/GeneratedFile.swift | 4 + .../Sources/Models/Model.swift | 4 + .../scenario1_parent_child_same_dir/child.yml | 5 + .../parent.yml | 9 + .../base/parent.yml | 4 + .../project/.swiftlint.yml | 4 + .../project/Sources/Core/Service.swift | 4 + .../project/Sources/Generated/Model.swift | 4 + .../base/.swiftlint.yml | 2 + .../project/.swiftlint.yml | 4 + .../project/Vendor/Critical/Important.swift | 4 + .../project/Vendor/Other/Library.swift | 4 + .../scenario4_nested_basic/.swiftlint.yml | 6 + .../ModuleA/.swiftlint.yml | 5 + .../scenario4_nested_basic/ModuleA/File.swift | 4 + .../ModuleA/Generated/File.swift | 4 + .../scenario4_nested_basic/ModuleB/File.swift | 4 + .../ModuleA/.swiftlint.yml | 2 + .../ModuleA/File.swift | 4 + .../root.yml | 5 + .../project/.swiftlint.yml | 8 + .../Sources/Generated/API.generated.swift | 4 + .../Sources/Models/User.generated.swift | 4 + .../project/Sources/Models/User.swift | 4 + 28 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 Tests/IntegrationTests/ConfigHierarchyPathResolutionTests.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/CoreFile.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/Generated/GeneratedFile.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/Models/Model.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/child.yml create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/parent.yml create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/base/parent.yml create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/.swiftlint.yml create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/Sources/Core/Service.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/Sources/Generated/Model.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/base/.swiftlint.yml create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/.swiftlint.yml create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/Vendor/Critical/Important.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/Vendor/Other/Library.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/.swiftlint.yml create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/.swiftlint.yml create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/File.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/Generated/File.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleB/File.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/.swiftlint.yml create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/File.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/root.yml create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/.swiftlint.yml create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Generated/API.generated.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Models/User.generated.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Models/User.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 4007569390..efeb8d93b9 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -8,6 +8,7 @@ excluded: - assets - Tests/BuiltInRulesTests/Resources - Tests/FileSystemAccessTests/Resources + - Tests/IntegrationTests/PathHierarchyFixtures # Enabled/disabled rules analyzer_rules: diff --git a/Package.swift b/Package.swift index cb777696a0..60823777e1 100644 --- a/Package.swift +++ b/Package.swift @@ -203,7 +203,8 @@ let package = Package( "TestHelpers", ], exclude: [ - "default_rule_configurations.yml" + "default_rule_configurations.yml", + "PathHierarchyFixtures", ], swiftSettings: swiftFeatures + targetedConcurrency // Set to strict once SwiftLintFramework is updated ), diff --git a/Tests/IntegrationTests/ConfigHierarchyPathResolutionTests.swift b/Tests/IntegrationTests/ConfigHierarchyPathResolutionTests.swift new file mode 100644 index 0000000000..7d9071554e --- /dev/null +++ b/Tests/IntegrationTests/ConfigHierarchyPathResolutionTests.swift @@ -0,0 +1,323 @@ +import Foundation +import SourceKittenFramework +import SwiftLintCore +import SwiftLintFramework +import TestHelpers +import XCTest + +/// Integration tests for configuration hierarchy path resolution. +/// These tests verify that included/excluded paths are correctly resolved when +/// merging parent/child configs and applying nested configs across different directories. +final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { + // MARK: - Setup + + private var fixturesPath: String { + #filePath.bridge() + .deletingLastPathComponent + .stringByAppendingPathComponent("PathHierarchyFixtures") + } + + private func fixturePath(_ scenario: String) -> String { + fixturesPath.stringByAppendingPathComponent(scenario) + } + + // MARK: - Helper Methods + + /// Returns the paths of lintable files relative to the fixture directory + private func lintableFilePaths( + in scenario: String, + configFile: String? = nil, + inPath: String = "" + ) -> [String] { + let scenarioPath = fixturePath(scenario) + let configFiles = configFile.map { [scenarioPath.stringByAppendingPathComponent($0)] } ?? [] + let config = Configuration(configurationFiles: configFiles) + + let searchPath = inPath.isEmpty ? scenarioPath : scenarioPath.stringByAppendingPathComponent(inPath) + let files = config.lintableFiles( + inPath: searchPath, + forceExclude: false, + excludeBy: .paths(excludedPaths: config.excludedPaths()) + ) + + // Convert to relative paths for easier assertions + return files.map { file in + file.path!.bridge().path(relativeTo: scenarioPath) + }.sorted() + } + + /// Asserts that a file is lintable (included) + private func assertLintable( + _ relativePath: String, + in scenario: String, + configFile: String? = nil, + inPath: String = "", + file: StaticString = #filePath, + line: UInt = #line + ) { + let paths = lintableFilePaths(in: scenario, configFile: configFile, inPath: inPath) + XCTAssertTrue( + paths.contains(relativePath), + "Expected \(relativePath) to be lintable. Lintable files: \(paths)", + file: file, + line: line + ) + } + + /// Asserts that a file is not lintable (excluded) + private func assertNotLintable( + _ relativePath: String, + in scenario: String, + configFile: String? = nil, + inPath: String = "", + file: StaticString = #filePath, + line: UInt = #line + ) { + let paths = lintableFilePaths(in: scenario, configFile: configFile, inPath: inPath) + XCTAssertFalse( + paths.contains(relativePath), + "Expected \(relativePath) to NOT be lintable. Lintable files: \(paths)", + file: file, + line: line + ) + } + + // MARK: - Parent/Child Config Tests + + func testParentChildSameDirectory() { + // Parent includes Sources, excludes Sources/Generated + // Child excludes Sources/Models + // Expected: Sources/CoreFile.swift included, others excluded + + let paths = lintableFilePaths(in: "scenario1_parent_child_same_dir", configFile: "parent.yml") + XCTAssertEqual(paths, ["Sources/CoreFile.swift"]) + } + + func testParentChildDifferentDirectories() { + // Parent in base/ includes ../project/Sources + // Child in project/ excludes Sources/Generated + // Expected: Sources/Core/Service.swift included, Sources/Generated/Model.swift excluded + + let paths = lintableFilePaths( + in: "scenario2_parent_child_different_dirs", + configFile: "project/.swiftlint.yml", + inPath: "project" + ) + XCTAssertEqual(paths, ["project/Sources/Core/Service.swift"]) + } + + func testChildOverridesParentExclusion() { + // Parent excludes Vendor + // Child includes Vendor/Critical + // Expected: Vendor/Critical/Important.swift included, Vendor/Other/Library.swift excluded + + let paths = lintableFilePaths( + in: "scenario3_child_overrides_parent_exclusion", + configFile: "project/.swiftlint.yml", + inPath: "project" + ) + XCTAssertEqual(paths, ["project/Vendor/Critical/Important.swift"]) + } + + func testParentIncludesChildExcludes() { + // Verify that child's exclusions are properly applied to parent's inclusions + let paths = lintableFilePaths(in: "scenario1_parent_child_same_dir", configFile: "parent.yml") + XCTAssertEqual(paths, ["Sources/CoreFile.swift"]) + } + + // MARK: - Nested Configuration Tests + + func testNestedConfigurationBasic() { + // Root config includes ModuleA and ModuleB + // ModuleA has nested config with opt-in rules + // Note: Nested configs affect rule configuration per-file, NOT file discovery + // File discovery uses only the root config's included/excluded paths + + let paths = lintableFilePaths(in: "scenario4_nested_basic", configFile: ".swiftlint.yml") + XCTAssertEqual(paths, [ + "ModuleA/File.swift", + "ModuleA/Generated/File.swift", + "ModuleB/File.swift", + ]) + } + + func testNestedConfigurationAppliesOnlyToSubdirectory() { + let scenario = "scenario4_nested_basic" + + // Verify ModuleA's nested config doesn't affect ModuleB + let scenarioPath = fixturePath(scenario) + let config = Configuration(configurationFiles: []) + + let moduleAFile = SwiftLintFile( + path: scenarioPath.stringByAppendingPathComponent("ModuleA/File.swift") + )! + let moduleBFile = SwiftLintFile( + path: scenarioPath.stringByAppendingPathComponent("ModuleB/File.swift") + )! + + let moduleAConfig = config.configuration(for: moduleAFile) + let moduleBConfig = config.configuration(for: moduleBFile) + + // ModuleA should have the nested config's opt-in rule + XCTAssertTrue( + moduleAConfig.rules.contains(where: { type(of: $0).identifier == "explicit_type_interface" }) + ) + + // ModuleB should not have it (only root config) + XCTAssertFalse( + moduleBConfig.rules.contains(where: { type(of: $0).identifier == "explicit_type_interface" }) + ) + } + + func testNestedConfigurationDisabledByConfigFlag() { + // When --config flag is used, nested configs should be ignored + let scenarioPath = fixturePath("scenario5_nested_disabled_by_config_flag") + let configFile = scenarioPath.stringByAppendingPathComponent("root.yml") + let config = Configuration(configurationFiles: [configFile]) + + let moduleAFile = SwiftLintFile( + path: scenarioPath.stringByAppendingPathComponent("ModuleA/File.swift") + )! + + let fileConfig = config.configuration(for: moduleAFile) + + // Should not have the opt-in rule from nested config + XCTAssertFalse( + fileConfig.rules.contains(where: { type(of: $0).identifier == "explicit_type_interface" }) + ) + } + + func testNestedConfigurationRuleApplication() { + let scenarioPath = fixturePath("scenario4_nested_basic") + + // Save current directory and change to scenario path + let previousDir = FileManager.default.currentDirectoryPath + defer { _ = FileManager.default.changeCurrentDirectoryPath(previousDir) } + + XCTAssertTrue(FileManager.default.changeCurrentDirectoryPath(scenarioPath)) + + // Create config without explicit config file to enable nested config discovery + let config = Configuration(configurationFiles: []) + + let moduleAFile = SwiftLintFile( + path: scenarioPath.stringByAppendingPathComponent("ModuleA/File.swift") + )! + let moduleBFile = SwiftLintFile( + path: scenarioPath.stringByAppendingPathComponent("ModuleB/File.swift") + )! + + let moduleAConfig = config.configuration(for: moduleAFile) + let moduleBConfig = config.configuration(for: moduleBFile) + + // ModuleA should have the nested config's opt-in rule + XCTAssertTrue( + moduleAConfig.rules.contains { type(of: $0).identifier == "explicit_type_interface" } + ) + + // ModuleB should not have the opt-in rule (no nested config) + XCTAssertFalse( + moduleBConfig.rules.contains { type(of: $0).identifier == "explicit_type_interface" } + ) + } + + // MARK: - Wildcard Pattern Tests + + func testWildcardPatternExclusion() { + // Config excludes **/*.generated.swift + // Expected: User.swift included, *.generated.swift files excluded + + let paths = lintableFilePaths( + in: "scenario6_wildcard_patterns", + configFile: "project/.swiftlint.yml", + inPath: "project" + ) + XCTAssertEqual(paths, ["project/Sources/Models/User.swift"]) + } + + func testWildcardPatternCount() { + let paths = lintableFilePaths( + in: "scenario6_wildcard_patterns", + configFile: "project/.swiftlint.yml", + inPath: "project" + ) + + XCTAssertEqual(paths, ["project/Sources/Models/User.swift"]) + } + + // MARK: - Path Normalization Tests + + func testRelativePathsNormalizedAcrossDirectories() { + // Parent in base/ references ../project/Sources + // This tests that paths are correctly normalized to absolute, then back to relative + + let scenarioPath = fixturePath("scenario2_parent_child_different_dirs") + let configFile = scenarioPath.stringByAppendingPathComponent("project/.swiftlint.yml") + let config = Configuration(configurationFiles: [configFile]) + + // Verify the config loaded successfully with parent reference + XCTAssertNotNil(config) + + // Verify that the merged included paths work correctly + let files = config.lintableFiles( + inPath: scenarioPath.stringByAppendingPathComponent("project"), + forceExclude: false, + excludeBy: .paths(excludedPaths: config.excludedPaths()) + ) + let relativePaths = files.map { $0.path!.bridge().path(relativeTo: scenarioPath) }.sorted() + + XCTAssertEqual(relativePaths, ["project/Sources/Core/Service.swift"]) + } + + func testExcludedPathsRespectConfigLocation() { + let scenarioPath = fixturePath("scenario2_parent_child_different_dirs") + + // Child config's "Sources/Generated" should be relative to project/ + let configFile = scenarioPath.stringByAppendingPathComponent("project/.swiftlint.yml") + let config = Configuration(configurationFiles: [configFile]) + + let files = config.lintableFiles( + inPath: scenarioPath.stringByAppendingPathComponent("project"), + forceExclude: false, + excludeBy: .paths(excludedPaths: config.excludedPaths()) + ) + let relativePaths = files.map { $0.path!.bridge().path(relativeTo: scenarioPath) }.sorted() + + // Generated should be excluded relative to project directory + XCTAssertEqual(relativePaths, ["project/Sources/Core/Service.swift"]) + } + + // MARK: - Edge Cases + + func testEmptyIncludedDefaultsToAll() { + // Use a config file that has excluded paths but no included paths + let scenarioPath = fixturePath("scenario1_parent_child_same_dir") + + // Create a minimal config with just excluded paths + let config = Configuration( + includedPaths: [], + excludedPaths: [scenarioPath.stringByAppendingPathComponent("Sources/Generated")] + ) + + let files = config.lintableFiles( + inPath: scenarioPath, + forceExclude: false, + excludeBy: .paths(excludedPaths: config.excludedPaths()) + ) + let relativePaths = files.map { $0.path!.bridge().path(relativeTo: scenarioPath) }.sorted() + + XCTAssertEqual(relativePaths, [ + "Sources/CoreFile.swift", + "Sources/Models/Model.swift", + ]) + } + + func testMultipleLevelsOfExclusion() { + let paths = lintableFilePaths(in: "scenario1_parent_child_same_dir", configFile: "parent.yml") + + XCTAssertEqual(paths, ["Sources/CoreFile.swift"]) + // Parent excludes Sources/Generated + XCTAssertFalse(paths.contains("Sources/Generated/GeneratedFile.swift")) + // Child excludes Sources/Models + XCTAssertFalse(paths.contains("Sources/Models/Model.swift")) + } +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/CoreFile.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/CoreFile.swift new file mode 100644 index 0000000000..0f480514d1 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/CoreFile.swift @@ -0,0 +1,4 @@ +// Should be included +struct CoreFile { + let name: String +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/Generated/GeneratedFile.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/Generated/GeneratedFile.swift new file mode 100644 index 0000000000..19566b994a --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/Generated/GeneratedFile.swift @@ -0,0 +1,4 @@ +// Should be excluded by parent +struct GeneratedFile { + let id: Int +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/Models/Model.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/Models/Model.swift new file mode 100644 index 0000000000..b012831f22 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/Models/Model.swift @@ -0,0 +1,4 @@ +// Should be excluded by child +struct Model { + let data: String +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/child.yml b/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/child.yml new file mode 100644 index 0000000000..70754c6866 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/child.yml @@ -0,0 +1,5 @@ +excluded: + - Sources/Models + +opt_in_rules: + - explicit_type_interface diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/parent.yml b/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/parent.yml new file mode 100644 index 0000000000..6996c77db7 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/parent.yml @@ -0,0 +1,9 @@ +included: + - Sources + +excluded: + - Sources/Generated + +line_length: 100 + +child_config: child.yml diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/base/parent.yml b/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/base/parent.yml new file mode 100644 index 0000000000..007a7b5853 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/base/parent.yml @@ -0,0 +1,4 @@ +included: + - ../project/Sources + +line_length: 120 diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/.swiftlint.yml b/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/.swiftlint.yml new file mode 100644 index 0000000000..b33e6e22a5 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/.swiftlint.yml @@ -0,0 +1,4 @@ +parent_config: ../base/parent.yml + +excluded: + - Sources/Generated diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/Sources/Core/Service.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/Sources/Core/Service.swift new file mode 100644 index 0000000000..d26531eb71 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/Sources/Core/Service.swift @@ -0,0 +1,4 @@ +// Should be included +struct CoreService { + func execute() {} +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/Sources/Generated/Model.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/Sources/Generated/Model.swift new file mode 100644 index 0000000000..215d08285e --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/Sources/Generated/Model.swift @@ -0,0 +1,4 @@ +// Should be excluded by child +struct GeneratedModel { + let id: Int +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/base/.swiftlint.yml b/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/base/.swiftlint.yml new file mode 100644 index 0000000000..557df346d7 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/base/.swiftlint.yml @@ -0,0 +1,2 @@ +excluded: + - Vendor diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/.swiftlint.yml b/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/.swiftlint.yml new file mode 100644 index 0000000000..0b573089c2 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/.swiftlint.yml @@ -0,0 +1,4 @@ +parent_config: ../base/.swiftlint.yml + +included: + - Vendor/Critical diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/Vendor/Critical/Important.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/Vendor/Critical/Important.swift new file mode 100644 index 0000000000..4df88245e7 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/Vendor/Critical/Important.swift @@ -0,0 +1,4 @@ +// Should be included (child overrides parent exclusion) +struct CriticalVendorCode { + let version: String +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/Vendor/Other/Library.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/Vendor/Other/Library.swift new file mode 100644 index 0000000000..d3cdb664ca --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/Vendor/Other/Library.swift @@ -0,0 +1,4 @@ +// Should be excluded (parent excludes Vendor) +struct OtherVendorCode { + let data: Int +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/.swiftlint.yml b/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/.swiftlint.yml new file mode 100644 index 0000000000..01f352fcdd --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/.swiftlint.yml @@ -0,0 +1,6 @@ +included: + - ModuleA + - ModuleB + +disabled_rules: + - line_length diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/.swiftlint.yml b/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/.swiftlint.yml new file mode 100644 index 0000000000..9357ecdbf2 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/.swiftlint.yml @@ -0,0 +1,5 @@ +excluded: + - Generated + +opt_in_rules: + - explicit_type_interface diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/File.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/File.swift new file mode 100644 index 0000000000..3bdbf4e5d7 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/File.swift @@ -0,0 +1,4 @@ +// Should be included with ModuleA nested config +struct ModuleAFile { + let name: String +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/Generated/File.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/Generated/File.swift new file mode 100644 index 0000000000..4252417f34 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/Generated/File.swift @@ -0,0 +1,4 @@ +// Should be excluded by nested config +struct GeneratedFile { + let id: Int +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleB/File.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleB/File.swift new file mode 100644 index 0000000000..66c03730b4 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleB/File.swift @@ -0,0 +1,4 @@ +// Should be included with root config only (no nested config) +struct ModuleBFile { + let data: String +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/.swiftlint.yml b/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/.swiftlint.yml new file mode 100644 index 0000000000..10c550eca1 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/.swiftlint.yml @@ -0,0 +1,2 @@ +opt_in_rules: + - explicit_type_interface diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/File.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/File.swift new file mode 100644 index 0000000000..828cd409d3 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/File.swift @@ -0,0 +1,4 @@ +// Nested config should be ignored when --config flag is used +struct ModuleAFile { + let name: String +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/root.yml b/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/root.yml new file mode 100644 index 0000000000..c4f16ef51b --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/root.yml @@ -0,0 +1,5 @@ +included: + - ModuleA + +disabled_rules: + - line_length diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/.swiftlint.yml b/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/.swiftlint.yml new file mode 100644 index 0000000000..f44625c2cb --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/.swiftlint.yml @@ -0,0 +1,8 @@ +included: + - Sources + +excluded: + - "**/*.generated.swift" + +disabled_rules: + - line_length diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Generated/API.generated.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Generated/API.generated.swift new file mode 100644 index 0000000000..c0d3b84517 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Generated/API.generated.swift @@ -0,0 +1,4 @@ +// Should be excluded by wildcard pattern +struct GeneratedAPI { + let endpoint: String +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Models/User.generated.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Models/User.generated.swift new file mode 100644 index 0000000000..b32ef5a745 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Models/User.generated.swift @@ -0,0 +1,4 @@ +// Should be excluded by wildcard pattern +struct GeneratedUser { + let id: Int +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Models/User.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Models/User.swift new file mode 100644 index 0000000000..bd633fe22e --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Models/User.swift @@ -0,0 +1,4 @@ +// Should be included +struct User { + let name: String +} From 6cda247d27aa4c0a59dddf2af63ad26b22896fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danny=20M=C3=B6sch?= Date: Wed, 25 Dec 2024 23:33:33 +0100 Subject: [PATCH 2/5] Improve performance of excluded files filter The current algorithm is like "collect all included files and subtract all excluded files". Collecting all included and all excluded files relies on the file system. This can become slow when the patterns used to exclude files resolve to a large number of files. The new approach only collects all lintable files and checks them against the exclude patterns. This can be done by in-memory string-regex-match and does therefore not require file system accesses. (cherry picked from commit 152355e36f97ef5cf1c420181237f2e89e653b28) --- BUILD | 1 + CHANGELOG.md | 4 + MODULE.bazel | 1 + Package.resolved | 9 ++ Package.swift | 4 + .../Extensions/String+SwiftLint.swift | 3 + .../Configuration+CommandLine.swift | 23 ++---- .../Configuration+LintableFiles.swift | 82 +++++++------------ Source/SwiftLintFramework/Helpers/Glob.swift | 24 ++++++ .../ConfigurationTests.swift | 39 ++++----- Tests/FileSystemAccessTests/GlobTests.swift | 28 +++++++ .../ConfigHierarchyPathResolutionTests.swift | 8 +- Tests/IntegrationTests/IntegrationTests.swift | 4 +- bazel/repos.bzl | 7 ++ 14 files changed, 141 insertions(+), 96 deletions(-) diff --git a/BUILD b/BUILD index 354fdfa73f..535858f1ec 100644 --- a/BUILD +++ b/BUILD @@ -70,6 +70,7 @@ swift_library( visibility = ["//visibility:public"], deps = [ ":Yams.wrapper", + "@FilenameMatcher", "@SourceKittenFramework", "@SwiftSyntax//:SwiftIDEUtils_opt", "@SwiftSyntax//:SwiftOperators_opt", diff --git a/CHANGELOG.md b/CHANGELOG.md index b71da70bf4..59422c4caf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ * Add new `unneeded_escaping` rule that detects closure parameters marked with `@escaping` that are never stored or captured escapingly. [SimplyDanny](https://github.com/SimplyDanny) + +* Improve performance when exclude patterns resolve to a large set of files. + [SimplyDanny](https://github.com/SimplyDanny) + [#5018](https://github.com/realm/SwiftLint/issues/5018) ### Bug Fixes diff --git a/MODULE.bazel b/MODULE.bazel index 08508411fd..77f37e97cf 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -15,6 +15,7 @@ bazel_dep(name = "rules_swift", version = "3.1.2", max_compatibility_level = 3, bazel_dep(name = "sourcekitten", version = "0.37.2", repo_name = "SourceKittenFramework") bazel_dep(name = "swift_argument_parser", version = "1.6.2", repo_name = "SwiftArgumentParser") +bazel_dep(name = "swift-filename-matcher", version = "2.0.1", repo_name = "FilenameMatcher") bazel_dep(name = "yams", version = "6.2.0", repo_name = "Yams") swiftlint_repos = use_extension("//bazel:repos.bzl", "swiftlint_repos_bzlmod") diff --git a/Package.resolved b/Package.resolved index 3f956deaeb..e10b5dace8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -36,6 +36,15 @@ "version" : "1.6.2" } }, + { + "identity" : "swift-filename-matcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ileitch/swift-filename-matcher", + "state" : { + "revision" : "eef5ac0b6b3cdc64b3039b037bed2def8a1edaeb", + "version" : "2.0.1" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 60823777e1..d8bed9a8ad 100644 --- a/Package.swift +++ b/Package.swift @@ -42,6 +42,7 @@ let package = Package( .package(url: "https://github.com/scottrhoyt/SwiftyTextTable.git", .upToNextMajor(from: "0.9.0")), .package(url: "https://github.com/JohnSundell/CollectionConcurrencyKit.git", .upToNextMajor(from: "0.2.0")), .package(url: "https://github.com/krzyzanowskim/CryptoSwift.git", .upToNextMajor(from: "1.9.0")), + .package(url: "https://github.com/ileitch/swift-filename-matcher", .upToNextMinor(from: "2.0.1")), ], targets: [ .executableTarget( @@ -65,6 +66,7 @@ let package = Package( .target( name: "SwiftLintFramework", dependencies: [ + .product(name: "FilenameMatcher", package: "swift-filename-matcher"), "SwiftLintBuiltInRules", "SwiftLintCore", "SwiftLintExtraRules", @@ -96,6 +98,7 @@ let package = Package( dependencies: [ .product(name: "CryptoSwift", package: "CryptoSwift", condition: .when(platforms: [.linux])), .target(name: "DyldWarningWorkaround", condition: .when(platforms: [.macOS])), + .product(name: "FilenameMatcher", package: "swift-filename-matcher"), .product(name: "SourceKittenFramework", package: "SourceKitten"), .product(name: "SwiftIDEUtils", package: "swift-syntax"), .product(name: "SwiftOperators", package: "swift-syntax"), @@ -167,6 +170,7 @@ let package = Package( .testTarget( name: "FileSystemAccessTests", dependencies: [ + .product(name: "FilenameMatcher", package: "swift-filename-matcher"), "SwiftLintFramework", "TestHelpers", "SwiftLintCoreMacros", diff --git a/Source/SwiftLintCore/Extensions/String+SwiftLint.swift b/Source/SwiftLintCore/Extensions/String+SwiftLint.swift index 2d6a431a43..357faf01fe 100644 --- a/Source/SwiftLintCore/Extensions/String+SwiftLint.swift +++ b/Source/SwiftLintCore/Extensions/String+SwiftLint.swift @@ -68,6 +68,9 @@ public extension String { /// Returns a new string, converting the path to a canonical absolute path. /// + /// > Important: This method might use an incorrect working directory internally. This can cause test failures + /// in Bazel builds but does not seem to cause trouble in production. + /// /// - returns: A new `String`. func absolutePathStandardized() -> String { bridge().absolutePathRepresentation().bridge().standardizingPath diff --git a/Source/SwiftLintFramework/Configuration+CommandLine.swift b/Source/SwiftLintFramework/Configuration+CommandLine.swift index 2935f2fc79..4c65708067 100644 --- a/Source/SwiftLintFramework/Configuration+CommandLine.swift +++ b/Source/SwiftLintFramework/Configuration+CommandLine.swift @@ -252,15 +252,12 @@ extension Configuration { guard options.forceExclude else { return files } - let scriptInputPaths = files.compactMap(\.path) - - if options.useExcludingByPrefix { - return filterExcludedPathsByPrefix(in: scriptInputPaths) - .map(SwiftLintFile.init(pathDeferringReading:)) - } - return filterExcludedPaths(excludedPaths(), in: scriptInputPaths) - .map(SwiftLintFile.init(pathDeferringReading:)) + return ( + visitor.options.useExcludingByPrefix + ? filterExcludedPathsByPrefix(in: scriptInputPaths) + : filterExcludedPaths(in: scriptInputPaths) + ).map(SwiftLintFile.init(pathDeferringReading:)) } if !options.quiet { let filesInfo: String @@ -272,14 +269,12 @@ extension Configuration { queuedPrintError("\(options.capitalizedVerb) Swift files \(filesInfo)") } - let excludeLintableFilesBy = options.useExcludingByPrefix - ? Configuration.ExcludeBy.prefix - : .paths(excludedPaths: excludedPaths()) - return options.paths.flatMap { + return visitor.options.paths.flatMap { self.lintableFiles( inPath: $0, - forceExclude: options.forceExclude, - excludeBy: excludeLintableFilesBy) + forceExclude: visitor.options.forceExclude, + excludeByPrefix: visitor.options.useExcludingByPrefix + ) } } diff --git a/Source/SwiftLintFramework/Configuration/Configuration+LintableFiles.swift b/Source/SwiftLintFramework/Configuration/Configuration+LintableFiles.swift index 1c91c0dbf5..8dc652dbf9 100644 --- a/Source/SwiftLintFramework/Configuration/Configuration+LintableFiles.swift +++ b/Source/SwiftLintFramework/Configuration/Configuration+LintableFiles.swift @@ -1,25 +1,22 @@ +import FilenameMatcher import Foundation extension Configuration { - public enum ExcludeBy { - case prefix - case paths(excludedPaths: [String]) - } - // MARK: Lintable Paths + /// Returns the files that can be linted by SwiftLint in the specified parent path. /// /// - parameter path: The parent path in which to search for lintable files. Can be a directory or a /// file. /// - parameter forceExclude: Whether or not excludes defined in this configuration should be applied even if /// `path` is an exact match. - /// - parameter excludeByPrefix: Whether or not uses excluding by prefix algorithm. + /// - parameter excludeByPrefix: Whether or not it uses the exclude-by-prefix algorithm. /// /// - returns: Files to lint. public func lintableFiles(inPath path: String, forceExclude: Bool, - excludeBy: ExcludeBy) -> [SwiftLintFile] { - lintablePaths(inPath: path, forceExclude: forceExclude, excludeBy: excludeBy) + excludeByPrefix: Bool) -> [SwiftLintFile] { + lintablePaths(inPath: path, forceExclude: forceExclude, excludeByPrefix: excludeByPrefix) .parallelCompactMap { SwiftLintFile(pathDeferringReading: $0) } @@ -31,27 +28,25 @@ extension Configuration { /// file. /// - parameter forceExclude: Whether or not excludes defined in this configuration should be applied even if /// `path` is an exact match. - /// - parameter excludeByPrefix: Whether or not uses excluding by prefix algorithm. + /// - parameter excludeByPrefix: Whether or not it uses the exclude-by-prefix algorithm. /// - parameter fileManager: The lintable file manager to use to search for lintable files. /// /// - returns: Paths for files to lint. internal func lintablePaths( inPath path: String, forceExclude: Bool, - excludeBy: ExcludeBy, + excludeByPrefix: Bool, fileManager: some LintableFileManager = FileManager.default ) -> [String] { if fileManager.isFile(atPath: path) { + let file = fileManager.filesToLint(inPath: path, rootDirectory: nil) if forceExclude { - switch excludeBy { - case .prefix: - return filterExcludedPathsByPrefix(in: [path.absolutePathStandardized()]) - case .paths(let excludedPaths): - return filterExcludedPaths(excludedPaths, in: [path.absolutePathStandardized()]) - } + return excludeByPrefix + ? filterExcludedPathsByPrefix(in: file) + : filterExcludedPaths(in: file) } // If path is a file and we're not forcing excludes, skip filtering with excluded/included paths - return [path] + return file } let pathsForPath = includedPaths.isEmpty ? fileManager.filesToLint(inPath: path, rootDirectory: nil) : [] @@ -59,35 +54,29 @@ extension Configuration { .flatMap(Glob.resolveGlob) .parallelFlatMap { fileManager.filesToLint(inPath: $0, rootDirectory: rootDirectory) } - switch excludeBy { - case .prefix: - return filterExcludedPathsByPrefix(in: pathsForPath, includedPaths) - case .paths(let excludedPaths): - return filterExcludedPaths(excludedPaths, in: pathsForPath, includedPaths) - } + return excludeByPrefix + ? filterExcludedPathsByPrefix(in: pathsForPath + includedPaths) + : filterExcludedPaths(in: pathsForPath + includedPaths) } /// Returns an array of file paths after removing the excluded paths as defined by this configuration. /// - /// - parameter fileManager: The lintable file manager to use to expand the excluded paths into all matching paths. - /// - parameter paths: The input paths to filter. + /// - parameter paths: The input paths to filter. /// /// - returns: The input paths after removing the excluded paths. - public func filterExcludedPaths( - _ excludedPaths: [String], - in paths: [String]... - ) -> [String] { - let allPaths = paths.flatMap(\.self) + public func filterExcludedPaths(in paths: [String]) -> [String] { #if os(Linux) - let result = NSMutableOrderedSet(capacity: allPaths.count) - result.addObjects(from: allPaths) + let result = NSMutableOrderedSet(capacity: paths.count) + result.addObjects(from: paths) #else - let result = NSMutableOrderedSet(array: allPaths) + let result = NSOrderedSet(array: paths) #endif - - result.minusSet(Set(excludedPaths)) - // swiftlint:disable:next force_cast - return result.map { $0 as! String } + let exclusionPatterns = self.excludedPaths.flatMap { + Glob.createFilenameMatchers(root: rootDirectory, pattern: $0) + } + return result.array + .parallelCompactMap { exclusionPatterns.anyMatch(filename: $0 as! String) ? nil : $0 as? String } + // swiftlint:disable:previous force_cast } /// Returns the file paths that are excluded by this configuration using filtering by absolute path prefix. @@ -96,25 +85,12 @@ extension Configuration { /// algorithm `filterExcludedPaths`. /// /// - returns: The input paths after removing the excluded paths. - public func filterExcludedPathsByPrefix(in paths: [String]...) -> [String] { - let allPaths = paths.flatMap(\.self) + public func filterExcludedPathsByPrefix(in paths: [String]) -> [String] { let excludedPaths = self.excludedPaths - .parallelFlatMap { @Sendable in Glob.resolveGlob($0) } + .parallelFlatMap { Glob.resolveGlob($0) } .map { $0.absolutePathStandardized() } - return allPaths.filter { path in + return paths.filter { path in !excludedPaths.contains { path.hasPrefix($0) } } } - - /// Returns the file paths that are excluded by this configuration after expanding them using the specified file - /// manager. - /// - /// - parameter fileManager: The file manager to get child paths in a given parent location. - /// - /// - returns: The expanded excluded file paths. - public func excludedPaths(fileManager: some LintableFileManager = FileManager.default) -> [String] { - excludedPaths - .flatMap(Glob.resolveGlob) - .parallelFlatMap { fileManager.filesToLint(inPath: $0, rootDirectory: rootDirectory) } - } } diff --git a/Source/SwiftLintFramework/Helpers/Glob.swift b/Source/SwiftLintFramework/Helpers/Glob.swift index 953819fd15..59df383635 100644 --- a/Source/SwiftLintFramework/Helpers/Glob.swift +++ b/Source/SwiftLintFramework/Helpers/Glob.swift @@ -1,3 +1,4 @@ +import FilenameMatcher import Foundation import SourceKittenFramework @@ -37,6 +38,29 @@ struct Glob { .map { $0.absolutePathStandardized() } } + static func createFilenameMatchers(root: String, pattern: String) -> [FilenameMatcher] { + var absolutPathPattern = pattern + if !pattern.starts(with: root) { + // If the root is not already part of the pattern, prepend it. + absolutPathPattern = root + (root.hasSuffix("/") ? "" : "/") + absolutPathPattern + } + absolutPathPattern = absolutPathPattern.absolutePathStandardized() + if pattern.hasSuffix(".swift") || pattern.hasSuffix("/**") { + // Suffix is already well defined. + return [FilenameMatcher(pattern: absolutPathPattern)] + } + if pattern.hasSuffix("/") { + // Matching all files in the folder. + return [FilenameMatcher(pattern: absolutPathPattern + "**")] + } + // The pattern could match files in the last folder in the path or all contained files if the last component + // represents folders. + return [ + FilenameMatcher(pattern: absolutPathPattern), + FilenameMatcher(pattern: absolutPathPattern + "/**"), + ] + } + // MARK: Private private static func expandGlobstar(pattern: String) -> [String] { diff --git a/Tests/FileSystemAccessTests/ConfigurationTests.swift b/Tests/FileSystemAccessTests/ConfigurationTests.swift index c17ca5e064..7b3601571e 100644 --- a/Tests/FileSystemAccessTests/ConfigurationTests.swift +++ b/Tests/FileSystemAccessTests/ConfigurationTests.swift @@ -9,7 +9,6 @@ import XCTest private let optInRules = RuleRegistry.shared.list.list.filter({ $0.1.init() is any OptInRule }).map(\.0) -// swiftlint:disable:next type_body_length final class ConfigurationTests: SwiftLintTestCase { // MARK: Setup & Teardown private var previousWorkingDir: String! // swiftlint:disable:this implicitly_unwrapped_optional @@ -315,10 +314,9 @@ final class ConfigurationTests: SwiftLintTestCase { excludedPaths: ["directory/excluded", "directory/ExcludedFile.swift"] ) - let excludedPaths = configuration.excludedPaths(fileManager: fileManager) let paths = configuration.lintablePaths(inPath: "", forceExclude: false, - excludeBy: .paths(excludedPaths: excludedPaths), + excludeByPrefix: false, fileManager: fileManager) XCTAssertEqual(["directory/File1.swift", "directory/File2.swift"].absolutePathsStandardized(), paths) } @@ -326,10 +324,9 @@ final class ConfigurationTests: SwiftLintTestCase { func testForceExcludesFile() { let fileManager = TestFileManager() let configuration = Configuration(excludedPaths: ["directory/ExcludedFile.swift"]) - let excludedPaths = configuration.excludedPaths(fileManager: fileManager) let paths = configuration.lintablePaths(inPath: "directory/ExcludedFile.swift", forceExclude: true, - excludeBy: .paths(excludedPaths: excludedPaths), + excludeByPrefix: false, fileManager: fileManager) XCTAssertEqual([], paths) } @@ -338,10 +335,9 @@ final class ConfigurationTests: SwiftLintTestCase { let fileManager = TestFileManager() let configuration = Configuration(includedPaths: ["directory"], excludedPaths: ["directory/ExcludedFile.swift", "directory/excluded"]) - let excludedPaths = configuration.excludedPaths(fileManager: fileManager) let paths = configuration.lintablePaths(inPath: "", forceExclude: true, - excludeBy: .paths(excludedPaths: excludedPaths), + excludeByPrefix: false, fileManager: fileManager) XCTAssertEqual(["directory/File1.swift", "directory/File2.swift"].absolutePathsStandardized(), paths) } @@ -349,10 +345,9 @@ final class ConfigurationTests: SwiftLintTestCase { func testForceExcludesDirectory() { let fileManager = TestFileManager() let configuration = Configuration(excludedPaths: ["directory/excluded", "directory/ExcludedFile.swift"]) - let excludedPaths = configuration.excludedPaths(fileManager: fileManager) let paths = configuration.lintablePaths(inPath: "directory", forceExclude: true, - excludeBy: .paths(excludedPaths: excludedPaths), + excludeByPrefix: false, fileManager: fileManager) XCTAssertEqual(["directory/File1.swift", "directory/File2.swift"].absolutePathsStandardized(), paths) } @@ -360,19 +355,17 @@ final class ConfigurationTests: SwiftLintTestCase { func testForceExcludesDirectoryThatIsNotInExcludedButHasChildrenThatAre() { let fileManager = TestFileManager() let configuration = Configuration(excludedPaths: ["directory/excluded", "directory/ExcludedFile.swift"]) - let excludedPaths = configuration.excludedPaths(fileManager: fileManager) let paths = configuration.lintablePaths(inPath: "directory", forceExclude: true, - excludeBy: .paths(excludedPaths: excludedPaths), + excludeByPrefix: false, fileManager: fileManager) XCTAssertEqual(["directory/File1.swift", "directory/File2.swift"].absolutePathsStandardized(), paths) } func testLintablePaths() { - let excluded = Configuration.default.excludedPaths(fileManager: TestFileManager()) let paths = Configuration.default.lintablePaths(inPath: Mock.Dir.level0, forceExclude: false, - excludeBy: .paths(excludedPaths: excluded)) + excludeByPrefix: false) let filenames = paths.map { $0.bridge().lastPathComponent }.sorted() let expectedFilenames = [ "DirectoryLevel1.swift", @@ -388,7 +381,7 @@ final class ConfigurationTests: SwiftLintTestCase { let configuration = Configuration(includedPaths: ["**/Level2"]) let paths = configuration.lintablePaths(inPath: Mock.Dir.level0, forceExclude: true, - excludeBy: .paths(excludedPaths: configuration.excludedPaths)) + excludeByPrefix: false) let filenames = paths.map { $0.bridge().lastPathComponent }.sorted() let expectedFilenames = ["Level2.swift", "Level3.swift"] @@ -401,10 +394,9 @@ final class ConfigurationTests: SwiftLintTestCase { excludedPaths: [Mock.Dir.level3.stringByAppendingPathComponent("*.swift")] ) - let excludedPaths = configuration.excludedPaths() let lintablePaths = configuration.lintablePaths(inPath: "", forceExclude: false, - excludeBy: .paths(excludedPaths: excludedPaths)) + excludeByPrefix: false) XCTAssertEqual(lintablePaths, []) } @@ -519,7 +511,7 @@ extension ConfigurationTests { ) let paths = configuration.lintablePaths(inPath: Mock.Dir.level0, forceExclude: false, - excludeBy: .prefix) + excludeByPrefix: true) let filenames = paths.map { $0.bridge().lastPathComponent } XCTAssertEqual(filenames, ["Level2.swift"]) } @@ -529,7 +521,7 @@ extension ConfigurationTests { let configuration = Configuration(excludedPaths: ["Level1/Level2/Level3/Level3.swift"]) let paths = configuration.lintablePaths(inPath: "Level1/Level2/Level3/Level3.swift", forceExclude: true, - excludeBy: .prefix) + excludeByPrefix: true) XCTAssertEqual([], paths) } @@ -539,7 +531,7 @@ extension ConfigurationTests { excludedPaths: ["Level1/Level1.swift"]) let paths = configuration.lintablePaths(inPath: "Level1", forceExclude: true, - excludeBy: .prefix) + excludeByPrefix: true) let filenames = paths.map { $0.bridge().lastPathComponent }.sorted() XCTAssertEqual(["Level2.swift", "Level3.swift"], filenames) } @@ -553,7 +545,7 @@ extension ConfigurationTests { ) let paths = configuration.lintablePaths(inPath: ".", forceExclude: true, - excludeBy: .prefix) + excludeByPrefix: true) let filenames = paths.map { $0.bridge().lastPathComponent }.sorted() XCTAssertEqual(["Level0.swift", "Level1.swift"], filenames) } @@ -567,7 +559,7 @@ extension ConfigurationTests { ) let paths = configuration.lintablePaths(inPath: ".", forceExclude: true, - excludeBy: .prefix) + excludeByPrefix: true) let filenames = paths.map { $0.bridge().lastPathComponent } XCTAssertEqual(["Level0.swift"], filenames) } @@ -579,7 +571,7 @@ extension ConfigurationTests { excludedPaths: ["Level1/*/*.swift", "Level1/*/*/*.swift"]) let paths = configuration.lintablePaths(inPath: "Level1", forceExclude: false, - excludeBy: .prefix) + excludeByPrefix: true) let filenames = paths.map { $0.bridge().lastPathComponent }.sorted() XCTAssertEqual(filenames, ["Level1.swift"]) } @@ -633,7 +625,8 @@ extension ConfigurationTests { private extension Sequence where Element == String { func absolutePathsStandardized() -> [String] { - map { $0.absolutePathStandardized() } + // In Bazel builds, absolute paths might be prefixed with `/private`. + map { String($0.absolutePathStandardized().trimmingPrefix("/private")) } } } diff --git a/Tests/FileSystemAccessTests/GlobTests.swift b/Tests/FileSystemAccessTests/GlobTests.swift index 84771f554d..ec8836f2a1 100644 --- a/Tests/FileSystemAccessTests/GlobTests.swift +++ b/Tests/FileSystemAccessTests/GlobTests.swift @@ -1,3 +1,4 @@ +import FilenameMatcher import TestHelpers import XCTest @@ -84,4 +85,31 @@ final class GlobTests: SwiftLintTestCase { let files = Glob.resolveGlob(mockPath.stringByAppendingPathComponent("**/*.swift")) XCTAssertEqual(files.sorted(), expectedFiles.sorted()) } + + func testCreateFilenameMatchers() { + func assertGlobMatch(root: String = "", pattern: String, filename: String) { + let matchers = Glob.createFilenameMatchers(root: root, pattern: pattern) + XCTAssert(matchers.anyMatch(filename: filename)) + } + + assertGlobMatch(root: "/a/b/", pattern: "c/*.swift", filename: "/a/b/c/d.swift") + assertGlobMatch(root: "/a", pattern: "**/*.swift", filename: "/a/b/c/d.swift") + assertGlobMatch(root: "/a", pattern: "**/*.swift", filename: "/a/b.swift") + assertGlobMatch(root: "/", pattern: "**/*.swift", filename: "/a/b.swift") + assertGlobMatch(root: "/", pattern: "a/**/b.swift", filename: "/a/b.swift") + assertGlobMatch(root: "/", pattern: "a/**/b.swift", filename: "/a/c/b.swift") + assertGlobMatch(root: "/", pattern: "**/*.swift", filename: "/a.swift") + assertGlobMatch(root: "/", pattern: "a/**/*.swift", filename: "/a/b/c.swift") + assertGlobMatch(root: "/", pattern: "a/**/*.swift", filename: "/a/b.swift") + assertGlobMatch(root: "/a/b", pattern: "/a/b/c/*.swift", filename: "/a/b/c/d.swift") + assertGlobMatch(root: "/a/", pattern: "/a/b/c/*.swift", filename: "/a/b/c/d.swift") + + assertGlobMatch(pattern: "/a/b/c", filename: "/a/b/c/d.swift") + assertGlobMatch(pattern: "/a/b/c/", filename: "/a/b/c/d.swift") + assertGlobMatch(pattern: "/a/b/c/*.swift", filename: "/a/b/c/d.swift") + assertGlobMatch(pattern: "/d.swift/*.swift", filename: "/d.swift/e.swift") + assertGlobMatch(pattern: "/a/**", filename: "/a/b/c/d.swift") + assertGlobMatch(root: "/", pattern: "**/*Test*", filename: "/a/b/c/MyTest2.swift") + assertGlobMatch(root: "/", pattern: "**/*Test*", filename: "/a/b/MyTests/c.swift") + } } diff --git a/Tests/IntegrationTests/ConfigHierarchyPathResolutionTests.swift b/Tests/IntegrationTests/ConfigHierarchyPathResolutionTests.swift index 7d9071554e..3a273f3ef3 100644 --- a/Tests/IntegrationTests/ConfigHierarchyPathResolutionTests.swift +++ b/Tests/IntegrationTests/ConfigHierarchyPathResolutionTests.swift @@ -37,7 +37,7 @@ final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { let files = config.lintableFiles( inPath: searchPath, forceExclude: false, - excludeBy: .paths(excludedPaths: config.excludedPaths()) + excludeByPrefix: false ) // Convert to relative paths for easier assertions @@ -261,7 +261,7 @@ final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { let files = config.lintableFiles( inPath: scenarioPath.stringByAppendingPathComponent("project"), forceExclude: false, - excludeBy: .paths(excludedPaths: config.excludedPaths()) + excludeByPrefix: false ) let relativePaths = files.map { $0.path!.bridge().path(relativeTo: scenarioPath) }.sorted() @@ -278,7 +278,7 @@ final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { let files = config.lintableFiles( inPath: scenarioPath.stringByAppendingPathComponent("project"), forceExclude: false, - excludeBy: .paths(excludedPaths: config.excludedPaths()) + excludeByPrefix: false ) let relativePaths = files.map { $0.path!.bridge().path(relativeTo: scenarioPath) }.sorted() @@ -301,7 +301,7 @@ final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { let files = config.lintableFiles( inPath: scenarioPath, forceExclude: false, - excludeBy: .paths(excludedPaths: config.excludedPaths()) + excludeByPrefix: false ) let relativePaths = files.map { $0.path!.bridge().path(relativeTo: scenarioPath) }.sorted() diff --git a/Tests/IntegrationTests/IntegrationTests.swift b/Tests/IntegrationTests/IntegrationTests.swift index 4f19b49706..e753d96fb4 100644 --- a/Tests/IntegrationTests/IntegrationTests.swift +++ b/Tests/IntegrationTests/IntegrationTests.swift @@ -25,7 +25,7 @@ final class IntegrationTests: SwiftLintTestCase { let swiftFiles = config.lintableFiles( inPath: "", forceExclude: false, - excludeBy: .paths(excludedPaths: config.excludedPaths())) + excludeByPrefix: false) XCTAssert( swiftFiles.contains(where: { #filePath.bridge().absolutePathRepresentation() == $0.path }), "current file should be included" @@ -50,7 +50,7 @@ final class IntegrationTests: SwiftLintTestCase { let swiftFiles = config.lintableFiles( inPath: "", forceExclude: false, - excludeBy: .paths(excludedPaths: config.excludedPaths())) + excludeByPrefix: false) let storage = RuleStorage() let corrections = swiftFiles.parallelMap { Linter(file: $0, configuration: config).collect(into: storage).correct(using: storage) diff --git a/bazel/repos.bzl b/bazel/repos.bzl index 1e9f257d6c..520184a446 100644 --- a/bazel/repos.bzl +++ b/bazel/repos.bzl @@ -66,6 +66,13 @@ def swiftlint_repos(bzlmod = False): url = "https://github.com/krzyzanowskim/CryptoSwift/archive/refs/tags/1.9.0.tar.gz", ) + http_archive( + name = "FilenameMatcher", + sha256 = "c0a6041be02ddd12f1cdde089f84dfa70e33e384cc476a786542a536d8401c6e", + strip_prefix = "swift-filename-matcher-2.0.1", + url = "https://github.com/ileitch/swift-filename-matcher/archive/refs/tags/2.0.1.tar.gz", + ) + def _swiftlint_repos_bzlmod(_): swiftlint_repos(bzlmod = True) From c2ab86273eb689a2142753d84b884ade30600f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danny=20M=C3=B6sch?= Date: Sun, 16 Nov 2025 00:13:11 +0100 Subject: [PATCH 3/5] Regression test --- .../ConfigHierarchyPathResolutionTests.swift | 354 +++++------------- .../root.yml | 0 .../ModuleA/.swiftlint.yml | 2 - .../ModuleA/File.swift | 4 - .../.swiftlint-exclude-thirdparty.yml | 4 + .../.swiftlint.yml | 6 + .../Generated/API.swift | 4 + .../MyProject/Package.swift | 7 + .../MyProject/Sources/App.swift | 4 + .../MyProject/SubModule/Package.swift | 7 + .../ZipFoundation/Archive+Writing.swift | 4 + .../ThirdParty/Library.swift | 4 + 12 files changed, 141 insertions(+), 259 deletions(-) rename Tests/IntegrationTests/PathHierarchyFixtures/{scenario5_nested_disabled_by_config_flag => scenario4_nested_basic}/root.yml (100%) delete mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/.swiftlint.yml delete mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/File.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/.swiftlint-exclude-thirdparty.yml create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/.swiftlint.yml create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/Generated/API.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/Package.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/Sources/App.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/SubModule/Package.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/ThirdParty/Lottie/Private/Model/DotLottie/ZipFoundation/Archive+Writing.swift create mode 100644 Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/ThirdParty/Library.swift diff --git a/Tests/IntegrationTests/ConfigHierarchyPathResolutionTests.swift b/Tests/IntegrationTests/ConfigHierarchyPathResolutionTests.swift index 3a273f3ef3..b125615578 100644 --- a/Tests/IntegrationTests/ConfigHierarchyPathResolutionTests.swift +++ b/Tests/IntegrationTests/ConfigHierarchyPathResolutionTests.swift @@ -1,202 +1,137 @@ -import Foundation import SourceKittenFramework -import SwiftLintCore import SwiftLintFramework import TestHelpers import XCTest -/// Integration tests for configuration hierarchy path resolution. -/// These tests verify that included/excluded paths are correctly resolved when -/// merging parent/child configs and applying nested configs across different directories. final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { - // MARK: - Setup - - private var fixturesPath: String { + private func fixturePath(_ scenario: String) -> String { #filePath.bridge() .deletingLastPathComponent .stringByAppendingPathComponent("PathHierarchyFixtures") + .stringByAppendingPathComponent(scenario) } - private func fixturePath(_ scenario: String) -> String { - fixturesPath.stringByAppendingPathComponent(scenario) - } - - // MARK: - Helper Methods - - /// Returns the paths of lintable files relative to the fixture directory - private func lintableFilePaths( - in scenario: String, - configFile: String? = nil, - inPath: String = "" - ) -> [String] { + /// Returns the paths of lintable files relative to the fixture directory. + private func lintableFilePaths(in scenario: String, configFile: String? = nil, inPath: String = ".") -> [String] { let scenarioPath = fixturePath(scenario) - let configFiles = configFile.map { [scenarioPath.stringByAppendingPathComponent($0)] } ?? [] - let config = Configuration(configurationFiles: configFiles) - let searchPath = inPath.isEmpty ? scenarioPath : scenarioPath.stringByAppendingPathComponent(inPath) + let previousDir = FileManager.default.currentDirectoryPath + defer { + _ = FileManager.default.changeCurrentDirectoryPath(previousDir) + } + XCTAssert(FileManager.default.changeCurrentDirectoryPath(scenarioPath)) + + let config = Configuration(configurationFiles: configFile.map { [$0] } ?? []) let files = config.lintableFiles( - inPath: searchPath, + inPath: inPath, forceExclude: false, excludeByPrefix: false ) - // Convert to relative paths for easier assertions - return files.map { file in - file.path!.bridge().path(relativeTo: scenarioPath) - }.sorted() - } - - /// Asserts that a file is lintable (included) - private func assertLintable( - _ relativePath: String, - in scenario: String, - configFile: String? = nil, - inPath: String = "", - file: StaticString = #filePath, - line: UInt = #line - ) { - let paths = lintableFilePaths(in: scenario, configFile: configFile, inPath: inPath) - XCTAssertTrue( - paths.contains(relativePath), - "Expected \(relativePath) to be lintable. Lintable files: \(paths)", - file: file, - line: line - ) - } - - /// Asserts that a file is not lintable (excluded) - private func assertNotLintable( - _ relativePath: String, - in scenario: String, - configFile: String? = nil, - inPath: String = "", - file: StaticString = #filePath, - line: UInt = #line - ) { - let paths = lintableFilePaths(in: scenario, configFile: configFile, inPath: inPath) - XCTAssertFalse( - paths.contains(relativePath), - "Expected \(relativePath) to NOT be lintable. Lintable files: \(paths)", - file: file, - line: line - ) + return files.map { $0.path!.bridge().path(relativeTo: scenarioPath) }.sorted() } - // MARK: - Parent/Child Config Tests - func testParentChildSameDirectory() { - // Parent includes Sources, excludes Sources/Generated - // Child excludes Sources/Models - // Expected: Sources/CoreFile.swift included, others excluded - - let paths = lintableFilePaths(in: "scenario1_parent_child_same_dir", configFile: "parent.yml") - XCTAssertEqual(paths, ["Sources/CoreFile.swift"]) + XCTAssertEqual( + lintableFilePaths(in: "scenario1_parent_child_same_dir", configFile: "parent.yml"), + ["Sources/CoreFile.swift"] + ) } func testParentChildDifferentDirectories() { - // Parent in base/ includes ../project/Sources - // Child in project/ excludes Sources/Generated - // Expected: Sources/Core/Service.swift included, Sources/Generated/Model.swift excluded - - let paths = lintableFilePaths( - in: "scenario2_parent_child_different_dirs", - configFile: "project/.swiftlint.yml", - inPath: "project" + XCTAssertEqual( + lintableFilePaths( + in: "scenario2_parent_child_different_dirs", + configFile: "project/.swiftlint.yml", + inPath: "project" + ), + ["project/Sources/Core/Service.swift"] ) - XCTAssertEqual(paths, ["project/Sources/Core/Service.swift"]) } func testChildOverridesParentExclusion() { - // Parent excludes Vendor - // Child includes Vendor/Critical - // Expected: Vendor/Critical/Important.swift included, Vendor/Other/Library.swift excluded - - let paths = lintableFilePaths( - in: "scenario3_child_overrides_parent_exclusion", - configFile: "project/.swiftlint.yml", - inPath: "project" + XCTAssertEqual( + lintableFilePaths( + in: "scenario3_child_overrides_parent_exclusion", + configFile: "project/.swiftlint.yml", + inPath: "project" + ), + ["project/Vendor/Critical/Important.swift"] ) - XCTAssertEqual(paths, ["project/Vendor/Critical/Important.swift"]) } func testParentIncludesChildExcludes() { - // Verify that child's exclusions are properly applied to parent's inclusions - let paths = lintableFilePaths(in: "scenario1_parent_child_same_dir", configFile: "parent.yml") - XCTAssertEqual(paths, ["Sources/CoreFile.swift"]) + XCTAssertEqual( + lintableFilePaths(in: "scenario1_parent_child_same_dir", configFile: "parent.yml"), + ["Sources/CoreFile.swift"] + ) } - // MARK: - Nested Configuration Tests - func testNestedConfigurationBasic() { - // Root config includes ModuleA and ModuleB - // ModuleA has nested config with opt-in rules - // Note: Nested configs affect rule configuration per-file, NOT file discovery - // File discovery uses only the root config's included/excluded paths - - let paths = lintableFilePaths(in: "scenario4_nested_basic", configFile: ".swiftlint.yml") - XCTAssertEqual(paths, [ - "ModuleA/File.swift", - "ModuleA/Generated/File.swift", - "ModuleB/File.swift", - ]) + XCTAssertEqual( + lintableFilePaths(in: "scenario4_nested_basic", configFile: ".swiftlint.yml"), + ["ModuleA/File.swift", "ModuleA/Generated/File.swift", "ModuleB/File.swift"] + ) } - func testNestedConfigurationAppliesOnlyToSubdirectory() { - let scenario = "scenario4_nested_basic" - - // Verify ModuleA's nested config doesn't affect ModuleB - let scenarioPath = fixturePath(scenario) - let config = Configuration(configurationFiles: []) - - let moduleAFile = SwiftLintFile( - path: scenarioPath.stringByAppendingPathComponent("ModuleA/File.swift") - )! - let moduleBFile = SwiftLintFile( - path: scenarioPath.stringByAppendingPathComponent("ModuleB/File.swift") - )! - - let moduleAConfig = config.configuration(for: moduleAFile) - let moduleBConfig = config.configuration(for: moduleBFile) - - // ModuleA should have the nested config's opt-in rule - XCTAssertTrue( - moduleAConfig.rules.contains(where: { type(of: $0).identifier == "explicit_type_interface" }) + func testWildcardPatternCount() { + XCTAssertEqual( + lintableFilePaths( + in: "scenario6_wildcard_patterns", + configFile: "project/.swiftlint.yml", + inPath: "project" + ), + ["project/Sources/Models/User.swift"] ) + } - // ModuleB should not have it (only root config) - XCTAssertFalse( - moduleBConfig.rules.contains(where: { type(of: $0).identifier == "explicit_type_interface" }) + func testLintChildFolder() { + XCTAssertEqual( + lintableFilePaths( + in: "scenario2_parent_child_different_dirs", + configFile: "project/.swiftlint.yml", + inPath: "project" + ), + ["project/Sources/Core/Service.swift"] ) } - func testNestedConfigurationDisabledByConfigFlag() { - // When --config flag is used, nested configs should be ignored - let scenarioPath = fixturePath("scenario5_nested_disabled_by_config_flag") - let configFile = scenarioPath.stringByAppendingPathComponent("root.yml") - let config = Configuration(configurationFiles: [configFile]) + func testEmptyIncludedDefaultsToAll() { + XCTAssertEqual( + lintableFilePaths( + in: "scenario7_wildcard_regression_5953", + configFile: ".swiftlint-exclude-thirdparty.yml" + ), + [ + "Generated/API.swift", + "MyProject/Package.swift", + "MyProject/Sources/App.swift", + "MyProject/SubModule/Package.swift", + ] + ) + } - let moduleAFile = SwiftLintFile( - path: scenarioPath.stringByAppendingPathComponent("ModuleA/File.swift") - )! + func testMultipleLevelsOfExclusion() { + XCTAssertEqual( + lintableFilePaths(in: "scenario1_parent_child_same_dir", configFile: "parent.yml"), + ["Sources/CoreFile.swift"] + ) + } - let fileConfig = config.configuration(for: moduleAFile) + func testConfigFromParentFolder() { + XCTAssertEqual( + lintableFilePaths(in: "scenario7_wildcard_regression_5953", configFile: ".swiftlint.yml"), + ["MyProject/Sources/App.swift"] + ) - // Should not have the opt-in rule from nested config - XCTAssertFalse( - fileConfig.rules.contains(where: { type(of: $0).identifier == "explicit_type_interface" }) + XCTAssertEqual( + lintableFilePaths(in: "scenario7_wildcard_regression_5953/MyProject", configFile: "../.swiftlint.yml"), + ["Sources/App.swift"] ) } - func testNestedConfigurationRuleApplication() { + func testNestedConfigurationAppliesOnlyToSubdirectory() { let scenarioPath = fixturePath("scenario4_nested_basic") - - // Save current directory and change to scenario path - let previousDir = FileManager.default.currentDirectoryPath - defer { _ = FileManager.default.changeCurrentDirectoryPath(previousDir) } - - XCTAssertTrue(FileManager.default.changeCurrentDirectoryPath(scenarioPath)) - - // Create config without explicit config file to enable nested config discovery let config = Configuration(configurationFiles: []) let moduleAFile = SwiftLintFile( @@ -206,118 +141,31 @@ final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { path: scenarioPath.stringByAppendingPathComponent("ModuleB/File.swift") )! - let moduleAConfig = config.configuration(for: moduleAFile) - let moduleBConfig = config.configuration(for: moduleBFile) - - // ModuleA should have the nested config's opt-in rule XCTAssertTrue( - moduleAConfig.rules.contains { type(of: $0).identifier == "explicit_type_interface" } + config.configuration(for: moduleAFile).rules + .map { type(of: $0).identifier } + .contains("explicit_type_interface") ) - // ModuleB should not have the opt-in rule (no nested config) XCTAssertFalse( - moduleBConfig.rules.contains { type(of: $0).identifier == "explicit_type_interface" } - ) - } - - // MARK: - Wildcard Pattern Tests - - func testWildcardPatternExclusion() { - // Config excludes **/*.generated.swift - // Expected: User.swift included, *.generated.swift files excluded - - let paths = lintableFilePaths( - in: "scenario6_wildcard_patterns", - configFile: "project/.swiftlint.yml", - inPath: "project" - ) - XCTAssertEqual(paths, ["project/Sources/Models/User.swift"]) - } - - func testWildcardPatternCount() { - let paths = lintableFilePaths( - in: "scenario6_wildcard_patterns", - configFile: "project/.swiftlint.yml", - inPath: "project" - ) - - XCTAssertEqual(paths, ["project/Sources/Models/User.swift"]) - } - - // MARK: - Path Normalization Tests - - func testRelativePathsNormalizedAcrossDirectories() { - // Parent in base/ references ../project/Sources - // This tests that paths are correctly normalized to absolute, then back to relative - - let scenarioPath = fixturePath("scenario2_parent_child_different_dirs") - let configFile = scenarioPath.stringByAppendingPathComponent("project/.swiftlint.yml") - let config = Configuration(configurationFiles: [configFile]) - - // Verify the config loaded successfully with parent reference - XCTAssertNotNil(config) - - // Verify that the merged included paths work correctly - let files = config.lintableFiles( - inPath: scenarioPath.stringByAppendingPathComponent("project"), - forceExclude: false, - excludeByPrefix: false - ) - let relativePaths = files.map { $0.path!.bridge().path(relativeTo: scenarioPath) }.sorted() - - XCTAssertEqual(relativePaths, ["project/Sources/Core/Service.swift"]) - } - - func testExcludedPathsRespectConfigLocation() { - let scenarioPath = fixturePath("scenario2_parent_child_different_dirs") - - // Child config's "Sources/Generated" should be relative to project/ - let configFile = scenarioPath.stringByAppendingPathComponent("project/.swiftlint.yml") - let config = Configuration(configurationFiles: [configFile]) - - let files = config.lintableFiles( - inPath: scenarioPath.stringByAppendingPathComponent("project"), - forceExclude: false, - excludeByPrefix: false + config.configuration(for: moduleBFile).rules + .map { type(of: $0).identifier } + .contains("explicit_type_interface") ) - let relativePaths = files.map { $0.path!.bridge().path(relativeTo: scenarioPath) }.sorted() - - // Generated should be excluded relative to project directory - XCTAssertEqual(relativePaths, ["project/Sources/Core/Service.swift"]) } - // MARK: - Edge Cases - - func testEmptyIncludedDefaultsToAll() { - // Use a config file that has excluded paths but no included paths - let scenarioPath = fixturePath("scenario1_parent_child_same_dir") + func testNestedConfigurationDisabledByConfigFlag() { + let scenarioPath = fixturePath("scenario4_nested_basic") + let configFile = scenarioPath.stringByAppendingPathComponent("root.yml") - // Create a minimal config with just excluded paths - let config = Configuration( - includedPaths: [], - excludedPaths: [scenarioPath.stringByAppendingPathComponent("Sources/Generated")] - ) + let moduleAFile = SwiftLintFile( + path: scenarioPath.stringByAppendingPathComponent("ModuleB/File.swift") + )! - let files = config.lintableFiles( - inPath: scenarioPath, - forceExclude: false, - excludeByPrefix: false + XCTAssertFalse( + Configuration(configurationFiles: [configFile]).configuration(for: moduleAFile).rules + .map { type(of: $0).identifier } + .contains("explicit_type_interface") ) - let relativePaths = files.map { $0.path!.bridge().path(relativeTo: scenarioPath) }.sorted() - - XCTAssertEqual(relativePaths, [ - "Sources/CoreFile.swift", - "Sources/Models/Model.swift", - ]) - } - - func testMultipleLevelsOfExclusion() { - let paths = lintableFilePaths(in: "scenario1_parent_child_same_dir", configFile: "parent.yml") - - XCTAssertEqual(paths, ["Sources/CoreFile.swift"]) - // Parent excludes Sources/Generated - XCTAssertFalse(paths.contains("Sources/Generated/GeneratedFile.swift")) - // Child excludes Sources/Models - XCTAssertFalse(paths.contains("Sources/Models/Model.swift")) } } diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/root.yml b/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/root.yml similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/root.yml rename to Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/root.yml diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/.swiftlint.yml b/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/.swiftlint.yml deleted file mode 100644 index 10c550eca1..0000000000 --- a/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/.swiftlint.yml +++ /dev/null @@ -1,2 +0,0 @@ -opt_in_rules: - - explicit_type_interface diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/File.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/File.swift deleted file mode 100644 index 828cd409d3..0000000000 --- a/Tests/IntegrationTests/PathHierarchyFixtures/scenario5_nested_disabled_by_config_flag/ModuleA/File.swift +++ /dev/null @@ -1,4 +0,0 @@ -// Nested config should be ignored when --config flag is used -struct ModuleAFile { - let name: String -} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/.swiftlint-exclude-thirdparty.yml b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/.swiftlint-exclude-thirdparty.yml new file mode 100644 index 0000000000..d815ed52c1 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/.swiftlint-exclude-thirdparty.yml @@ -0,0 +1,4 @@ +excluded: + - "ThirdParty/**" + - "*/ThirdParty/**" + - "**/ThirdParty/**" diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/.swiftlint.yml b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/.swiftlint.yml new file mode 100644 index 0000000000..e9c04c7f29 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/.swiftlint.yml @@ -0,0 +1,6 @@ +excluded: + - "ThirdParty/**" + - "*/ThirdParty/**" + - "**/ThirdParty/**" + - "Generated" + - "**/Package.swift" diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/Generated/API.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/Generated/API.swift new file mode 100644 index 0000000000..5722826739 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/Generated/API.swift @@ -0,0 +1,4 @@ +// Should NOT be linted - in Generated +struct GeneratedCode { + var value: String +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/Package.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/Package.swift new file mode 100644 index 0000000000..8f7ea67e52 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/Package.swift @@ -0,0 +1,7 @@ +// Should NOT be linted - Package.swift +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "MyPackage" +) diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/Sources/App.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/Sources/App.swift new file mode 100644 index 0000000000..8685cd6b3a --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/Sources/App.swift @@ -0,0 +1,4 @@ +// Should be linted +class MyApp { + func doSomething() {} +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/SubModule/Package.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/SubModule/Package.swift new file mode 100644 index 0000000000..35a7888372 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/SubModule/Package.swift @@ -0,0 +1,7 @@ +// Should NOT be linted - nested Package.swift +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "SubPackage" +) diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/ThirdParty/Lottie/Private/Model/DotLottie/ZipFoundation/Archive+Writing.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/ThirdParty/Lottie/Private/Model/DotLottie/ZipFoundation/Archive+Writing.swift new file mode 100644 index 0000000000..1d82293850 --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/ThirdParty/Lottie/Private/Model/DotLottie/ZipFoundation/Archive+Writing.swift @@ -0,0 +1,4 @@ +// Should NOT be linted - in ThirdParty +class LottieAnimation { + func animate() {} +} diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/ThirdParty/Library.swift b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/ThirdParty/Library.swift new file mode 100644 index 0000000000..6f626381bd --- /dev/null +++ b/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/ThirdParty/Library.swift @@ -0,0 +1,4 @@ +// Should NOT be linted - in ThirdParty at root +class ThirdPartyLib { + func process() {} +} From bf134fe2e96fbcd5cd63af8b328ff9f7800d1e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danny=20M=C3=B6sch?= Date: Sun, 16 Nov 2025 15:53:47 +0100 Subject: [PATCH 4/5] Move fixtures into resources folder --- .github/copilot-instructions.md | 2 +- .swiftlint.yml | 2 +- Package.swift | 3 +- Source/swiftlint-dev/Rules+Register.swift | 1 + Tests/BUILD | 2 +- ....swift => ConfigPathResolutionTests.swift} | 30 +++++++++---------- .../Sources/CoreFile.swift | 0 .../Sources/Generated/GeneratedFile.swift | 0 .../Sources/Models/Model.swift | 0 .../_1_parent_child_same_dir}/child.yml | 0 .../_1_parent_child_same_dir}/parent.yml | 0 .../base/parent.yml | 0 .../project/.swiftlint.yml | 0 .../project/Sources/Core/Service.swift | 0 .../project/Sources/Generated/Model.swift | 0 .../base/.swiftlint.yml | 0 .../project/.swiftlint.yml | 0 .../project/Vendor/Critical/Important.swift | 0 .../project/Vendor/Other/Library.swift | 0 .../_4_nested_basic}/.swiftlint.yml | 0 .../_4_nested_basic}/ModuleA/.swiftlint.yml | 0 .../_4_nested_basic}/ModuleA/File.swift | 0 .../ModuleA/Generated/File.swift | 0 .../_4_nested_basic}/ModuleB/File.swift | 0 .../_4_nested_basic}/root.yml | 0 .../project/.swiftlint.yml | 0 .../Sources/Generated/API.generated.swift | 0 .../Sources/Models/User.generated.swift | 0 .../project/Sources/Models/User.swift | 0 .../.swiftlint-exclude-thirdparty.yml | 0 .../.swiftlint.yml | 0 .../Generated/API.swift | 0 .../MyProject/Package.swift | 0 .../MyProject/Sources/App.swift | 0 .../MyProject/SubModule/Package.swift | 0 .../ZipFoundation/Archive+Writing.swift | 0 .../ThirdParty/Library.swift | 0 .../default_rule_configurations.yml | 0 38 files changed, 20 insertions(+), 20 deletions(-) rename Tests/IntegrationTests/{ConfigHierarchyPathResolutionTests.swift => ConfigPathResolutionTests.swift} (79%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario1_parent_child_same_dir => Resources/_1_parent_child_same_dir}/Sources/CoreFile.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario1_parent_child_same_dir => Resources/_1_parent_child_same_dir}/Sources/Generated/GeneratedFile.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario1_parent_child_same_dir => Resources/_1_parent_child_same_dir}/Sources/Models/Model.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario1_parent_child_same_dir => Resources/_1_parent_child_same_dir}/child.yml (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario1_parent_child_same_dir => Resources/_1_parent_child_same_dir}/parent.yml (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario2_parent_child_different_dirs => Resources/_2_parent_child_different_dirs}/base/parent.yml (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario2_parent_child_different_dirs => Resources/_2_parent_child_different_dirs}/project/.swiftlint.yml (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario2_parent_child_different_dirs => Resources/_2_parent_child_different_dirs}/project/Sources/Core/Service.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario2_parent_child_different_dirs => Resources/_2_parent_child_different_dirs}/project/Sources/Generated/Model.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion => Resources/_3_child_overrides_parent_exclusion}/base/.swiftlint.yml (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion => Resources/_3_child_overrides_parent_exclusion}/project/.swiftlint.yml (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion => Resources/_3_child_overrides_parent_exclusion}/project/Vendor/Critical/Important.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion => Resources/_3_child_overrides_parent_exclusion}/project/Vendor/Other/Library.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario4_nested_basic => Resources/_4_nested_basic}/.swiftlint.yml (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario4_nested_basic => Resources/_4_nested_basic}/ModuleA/.swiftlint.yml (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario4_nested_basic => Resources/_4_nested_basic}/ModuleA/File.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario4_nested_basic => Resources/_4_nested_basic}/ModuleA/Generated/File.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario4_nested_basic => Resources/_4_nested_basic}/ModuleB/File.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario4_nested_basic => Resources/_4_nested_basic}/root.yml (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario6_wildcard_patterns => Resources/_5_wildcard_patterns}/project/.swiftlint.yml (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario6_wildcard_patterns => Resources/_5_wildcard_patterns}/project/Sources/Generated/API.generated.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario6_wildcard_patterns => Resources/_5_wildcard_patterns}/project/Sources/Models/User.generated.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario6_wildcard_patterns => Resources/_5_wildcard_patterns}/project/Sources/Models/User.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario7_wildcard_regression_5953 => Resources/_6_wildcards_from_nested_folder}/.swiftlint-exclude-thirdparty.yml (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario7_wildcard_regression_5953 => Resources/_6_wildcards_from_nested_folder}/.swiftlint.yml (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario7_wildcard_regression_5953 => Resources/_6_wildcards_from_nested_folder}/Generated/API.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario7_wildcard_regression_5953 => Resources/_6_wildcards_from_nested_folder}/MyProject/Package.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario7_wildcard_regression_5953 => Resources/_6_wildcards_from_nested_folder}/MyProject/Sources/App.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario7_wildcard_regression_5953 => Resources/_6_wildcards_from_nested_folder}/MyProject/SubModule/Package.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario7_wildcard_regression_5953 => Resources/_6_wildcards_from_nested_folder}/MyProject/ThirdParty/Lottie/Private/Model/DotLottie/ZipFoundation/Archive+Writing.swift (100%) rename Tests/IntegrationTests/{PathHierarchyFixtures/scenario7_wildcard_regression_5953 => Resources/_6_wildcards_from_nested_folder}/ThirdParty/Library.swift (100%) rename Tests/IntegrationTests/{ => Resources}/default_rule_configurations.yml (100%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 43a85af20e..b55e3b2767 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,7 +4,7 @@ Linting rules are defined in `Source/SwiftLintBuiltInRules/Rules`. If someone me User-facing changes must be documented in the `CHANGELOG.md` file, which is organized by version. New entries always go into the "Main" section. They give credit to the person who has made the change and they reference the issue which has been fixed by the change. -All changes on configuration options must be reflected in `Tests/IntegrationTests/default_rule_configurations.yml`. This can be achieved by running `swift run swiftlint-dev rules register`. Running this command is also necessary when new rules got added or removed to (un-)register them from/in the list of built-in rules and tests verifying all examples in rule descriptions. +All changes on configuration options must be reflected in `Tests/IntegrationTests/Resources/default_rule_configurations.yml`. This can be achieved by running `swift run swiftlint-dev rules register`. Running this command is also necessary when new rules got added or removed to (un-)register them from/in the list of built-in rules and tests verifying all examples in rule descriptions. For some rules, there are dedicated tests in `Tests/BuiltInRulesTests`. However, they are typically not required as all the examples in the rule descriptions are automatically tested. The examples in the rule descriptions are also used to generate documentation for the rules. If an example presents a very pathological case, that's helpful for testing but not for user documentation, you can add the `excludeFromDocumentation: true` parameter to the example initializer. Important is that all examples in the rule description are verified by running `RuleGeneratedTests` for rule modified rules. diff --git a/.swiftlint.yml b/.swiftlint.yml index efeb8d93b9..83b507bc5f 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -8,7 +8,7 @@ excluded: - assets - Tests/BuiltInRulesTests/Resources - Tests/FileSystemAccessTests/Resources - - Tests/IntegrationTests/PathHierarchyFixtures + - Tests/IntegrationTests/Resources # Enabled/disabled rules analyzer_rules: diff --git a/Package.swift b/Package.swift index d8bed9a8ad..562d8cfdea 100644 --- a/Package.swift +++ b/Package.swift @@ -207,8 +207,7 @@ let package = Package( "TestHelpers", ], exclude: [ - "default_rule_configurations.yml", - "PathHierarchyFixtures", + "Resources", ], swiftSettings: swiftFeatures + targetedConcurrency // Set to strict once SwiftLintFramework is updated ), diff --git a/Source/swiftlint-dev/Rules+Register.swift b/Source/swiftlint-dev/Rules+Register.swift index 51760bf302..c756734df4 100644 --- a/Source/swiftlint-dev/Rules+Register.swift +++ b/Source/swiftlint-dev/Rules+Register.swift @@ -269,6 +269,7 @@ private extension SwiftLintDev.Rules.Register { .write( to: testsParentDirectory .appendingPathComponent("IntegrationTests", isDirectory: true) + .appendingPathComponent("Resources", isDirectory: true) .appendingPathComponent("default_rule_configurations.yml", isDirectory: false), atomically: true, encoding: .utf8 diff --git a/Tests/BUILD b/Tests/BUILD index 476c4b7109..4b2a087d27 100644 --- a/Tests/BUILD +++ b/Tests/BUILD @@ -166,7 +166,7 @@ swift_library( swift_test( name = "IntegrationTests", data = [ - "IntegrationTests/default_rule_configurations.yml", + "IntegrationTests/Resources/default_rule_configurations.yml", "//:LintInputs", ], visibility = ["//visibility:public"], diff --git a/Tests/IntegrationTests/ConfigHierarchyPathResolutionTests.swift b/Tests/IntegrationTests/ConfigPathResolutionTests.swift similarity index 79% rename from Tests/IntegrationTests/ConfigHierarchyPathResolutionTests.swift rename to Tests/IntegrationTests/ConfigPathResolutionTests.swift index b125615578..6959cd8025 100644 --- a/Tests/IntegrationTests/ConfigHierarchyPathResolutionTests.swift +++ b/Tests/IntegrationTests/ConfigPathResolutionTests.swift @@ -3,11 +3,11 @@ import SwiftLintFramework import TestHelpers import XCTest -final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { +final class ConfigPathResolutionTests: SwiftLintTestCase { private func fixturePath(_ scenario: String) -> String { #filePath.bridge() .deletingLastPathComponent - .stringByAppendingPathComponent("PathHierarchyFixtures") + .stringByAppendingPathComponent("Resources") .stringByAppendingPathComponent(scenario) } @@ -33,7 +33,7 @@ final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { func testParentChildSameDirectory() { XCTAssertEqual( - lintableFilePaths(in: "scenario1_parent_child_same_dir", configFile: "parent.yml"), + lintableFilePaths(in: "_1_parent_child_same_dir", configFile: "parent.yml"), ["Sources/CoreFile.swift"] ) } @@ -41,7 +41,7 @@ final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { func testParentChildDifferentDirectories() { XCTAssertEqual( lintableFilePaths( - in: "scenario2_parent_child_different_dirs", + in: "_2_parent_child_different_dirs", configFile: "project/.swiftlint.yml", inPath: "project" ), @@ -52,7 +52,7 @@ final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { func testChildOverridesParentExclusion() { XCTAssertEqual( lintableFilePaths( - in: "scenario3_child_overrides_parent_exclusion", + in: "_3_child_overrides_parent_exclusion", configFile: "project/.swiftlint.yml", inPath: "project" ), @@ -62,14 +62,14 @@ final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { func testParentIncludesChildExcludes() { XCTAssertEqual( - lintableFilePaths(in: "scenario1_parent_child_same_dir", configFile: "parent.yml"), + lintableFilePaths(in: "_1_parent_child_same_dir", configFile: "parent.yml"), ["Sources/CoreFile.swift"] ) } func testNestedConfigurationBasic() { XCTAssertEqual( - lintableFilePaths(in: "scenario4_nested_basic", configFile: ".swiftlint.yml"), + lintableFilePaths(in: "_4_nested_basic", configFile: ".swiftlint.yml"), ["ModuleA/File.swift", "ModuleA/Generated/File.swift", "ModuleB/File.swift"] ) } @@ -77,7 +77,7 @@ final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { func testWildcardPatternCount() { XCTAssertEqual( lintableFilePaths( - in: "scenario6_wildcard_patterns", + in: "_5_wildcard_patterns", configFile: "project/.swiftlint.yml", inPath: "project" ), @@ -88,7 +88,7 @@ final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { func testLintChildFolder() { XCTAssertEqual( lintableFilePaths( - in: "scenario2_parent_child_different_dirs", + in: "_2_parent_child_different_dirs", configFile: "project/.swiftlint.yml", inPath: "project" ), @@ -99,7 +99,7 @@ final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { func testEmptyIncludedDefaultsToAll() { XCTAssertEqual( lintableFilePaths( - in: "scenario7_wildcard_regression_5953", + in: "_6_wildcards_from_nested_folder", configFile: ".swiftlint-exclude-thirdparty.yml" ), [ @@ -113,25 +113,25 @@ final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { func testMultipleLevelsOfExclusion() { XCTAssertEqual( - lintableFilePaths(in: "scenario1_parent_child_same_dir", configFile: "parent.yml"), + lintableFilePaths(in: "_1_parent_child_same_dir", configFile: "parent.yml"), ["Sources/CoreFile.swift"] ) } func testConfigFromParentFolder() { XCTAssertEqual( - lintableFilePaths(in: "scenario7_wildcard_regression_5953", configFile: ".swiftlint.yml"), + lintableFilePaths(in: "_6_wildcards_from_nested_folder", configFile: ".swiftlint.yml"), ["MyProject/Sources/App.swift"] ) XCTAssertEqual( - lintableFilePaths(in: "scenario7_wildcard_regression_5953/MyProject", configFile: "../.swiftlint.yml"), + lintableFilePaths(in: "_6_wildcards_from_nested_folder/MyProject", configFile: "../.swiftlint.yml"), ["Sources/App.swift"] ) } func testNestedConfigurationAppliesOnlyToSubdirectory() { - let scenarioPath = fixturePath("scenario4_nested_basic") + let scenarioPath = fixturePath("_4_nested_basic") let config = Configuration(configurationFiles: []) let moduleAFile = SwiftLintFile( @@ -155,7 +155,7 @@ final class ConfigHierarchyPathResolutionTests: SwiftLintTestCase { } func testNestedConfigurationDisabledByConfigFlag() { - let scenarioPath = fixturePath("scenario4_nested_basic") + let scenarioPath = fixturePath("_4_nested_basic") let configFile = scenarioPath.stringByAppendingPathComponent("root.yml") let moduleAFile = SwiftLintFile( diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/CoreFile.swift b/Tests/IntegrationTests/Resources/_1_parent_child_same_dir/Sources/CoreFile.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/CoreFile.swift rename to Tests/IntegrationTests/Resources/_1_parent_child_same_dir/Sources/CoreFile.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/Generated/GeneratedFile.swift b/Tests/IntegrationTests/Resources/_1_parent_child_same_dir/Sources/Generated/GeneratedFile.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/Generated/GeneratedFile.swift rename to Tests/IntegrationTests/Resources/_1_parent_child_same_dir/Sources/Generated/GeneratedFile.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/Models/Model.swift b/Tests/IntegrationTests/Resources/_1_parent_child_same_dir/Sources/Models/Model.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/Sources/Models/Model.swift rename to Tests/IntegrationTests/Resources/_1_parent_child_same_dir/Sources/Models/Model.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/child.yml b/Tests/IntegrationTests/Resources/_1_parent_child_same_dir/child.yml similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/child.yml rename to Tests/IntegrationTests/Resources/_1_parent_child_same_dir/child.yml diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/parent.yml b/Tests/IntegrationTests/Resources/_1_parent_child_same_dir/parent.yml similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario1_parent_child_same_dir/parent.yml rename to Tests/IntegrationTests/Resources/_1_parent_child_same_dir/parent.yml diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/base/parent.yml b/Tests/IntegrationTests/Resources/_2_parent_child_different_dirs/base/parent.yml similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/base/parent.yml rename to Tests/IntegrationTests/Resources/_2_parent_child_different_dirs/base/parent.yml diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/.swiftlint.yml b/Tests/IntegrationTests/Resources/_2_parent_child_different_dirs/project/.swiftlint.yml similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/.swiftlint.yml rename to Tests/IntegrationTests/Resources/_2_parent_child_different_dirs/project/.swiftlint.yml diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/Sources/Core/Service.swift b/Tests/IntegrationTests/Resources/_2_parent_child_different_dirs/project/Sources/Core/Service.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/Sources/Core/Service.swift rename to Tests/IntegrationTests/Resources/_2_parent_child_different_dirs/project/Sources/Core/Service.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/Sources/Generated/Model.swift b/Tests/IntegrationTests/Resources/_2_parent_child_different_dirs/project/Sources/Generated/Model.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario2_parent_child_different_dirs/project/Sources/Generated/Model.swift rename to Tests/IntegrationTests/Resources/_2_parent_child_different_dirs/project/Sources/Generated/Model.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/base/.swiftlint.yml b/Tests/IntegrationTests/Resources/_3_child_overrides_parent_exclusion/base/.swiftlint.yml similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/base/.swiftlint.yml rename to Tests/IntegrationTests/Resources/_3_child_overrides_parent_exclusion/base/.swiftlint.yml diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/.swiftlint.yml b/Tests/IntegrationTests/Resources/_3_child_overrides_parent_exclusion/project/.swiftlint.yml similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/.swiftlint.yml rename to Tests/IntegrationTests/Resources/_3_child_overrides_parent_exclusion/project/.swiftlint.yml diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/Vendor/Critical/Important.swift b/Tests/IntegrationTests/Resources/_3_child_overrides_parent_exclusion/project/Vendor/Critical/Important.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/Vendor/Critical/Important.swift rename to Tests/IntegrationTests/Resources/_3_child_overrides_parent_exclusion/project/Vendor/Critical/Important.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/Vendor/Other/Library.swift b/Tests/IntegrationTests/Resources/_3_child_overrides_parent_exclusion/project/Vendor/Other/Library.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario3_child_overrides_parent_exclusion/project/Vendor/Other/Library.swift rename to Tests/IntegrationTests/Resources/_3_child_overrides_parent_exclusion/project/Vendor/Other/Library.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/.swiftlint.yml b/Tests/IntegrationTests/Resources/_4_nested_basic/.swiftlint.yml similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/.swiftlint.yml rename to Tests/IntegrationTests/Resources/_4_nested_basic/.swiftlint.yml diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/.swiftlint.yml b/Tests/IntegrationTests/Resources/_4_nested_basic/ModuleA/.swiftlint.yml similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/.swiftlint.yml rename to Tests/IntegrationTests/Resources/_4_nested_basic/ModuleA/.swiftlint.yml diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/File.swift b/Tests/IntegrationTests/Resources/_4_nested_basic/ModuleA/File.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/File.swift rename to Tests/IntegrationTests/Resources/_4_nested_basic/ModuleA/File.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/Generated/File.swift b/Tests/IntegrationTests/Resources/_4_nested_basic/ModuleA/Generated/File.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleA/Generated/File.swift rename to Tests/IntegrationTests/Resources/_4_nested_basic/ModuleA/Generated/File.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleB/File.swift b/Tests/IntegrationTests/Resources/_4_nested_basic/ModuleB/File.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/ModuleB/File.swift rename to Tests/IntegrationTests/Resources/_4_nested_basic/ModuleB/File.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/root.yml b/Tests/IntegrationTests/Resources/_4_nested_basic/root.yml similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario4_nested_basic/root.yml rename to Tests/IntegrationTests/Resources/_4_nested_basic/root.yml diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/.swiftlint.yml b/Tests/IntegrationTests/Resources/_5_wildcard_patterns/project/.swiftlint.yml similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/.swiftlint.yml rename to Tests/IntegrationTests/Resources/_5_wildcard_patterns/project/.swiftlint.yml diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Generated/API.generated.swift b/Tests/IntegrationTests/Resources/_5_wildcard_patterns/project/Sources/Generated/API.generated.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Generated/API.generated.swift rename to Tests/IntegrationTests/Resources/_5_wildcard_patterns/project/Sources/Generated/API.generated.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Models/User.generated.swift b/Tests/IntegrationTests/Resources/_5_wildcard_patterns/project/Sources/Models/User.generated.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Models/User.generated.swift rename to Tests/IntegrationTests/Resources/_5_wildcard_patterns/project/Sources/Models/User.generated.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Models/User.swift b/Tests/IntegrationTests/Resources/_5_wildcard_patterns/project/Sources/Models/User.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario6_wildcard_patterns/project/Sources/Models/User.swift rename to Tests/IntegrationTests/Resources/_5_wildcard_patterns/project/Sources/Models/User.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/.swiftlint-exclude-thirdparty.yml b/Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/.swiftlint-exclude-thirdparty.yml similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/.swiftlint-exclude-thirdparty.yml rename to Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/.swiftlint-exclude-thirdparty.yml diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/.swiftlint.yml b/Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/.swiftlint.yml similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/.swiftlint.yml rename to Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/.swiftlint.yml diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/Generated/API.swift b/Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/Generated/API.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/Generated/API.swift rename to Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/Generated/API.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/Package.swift b/Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/MyProject/Package.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/Package.swift rename to Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/MyProject/Package.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/Sources/App.swift b/Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/MyProject/Sources/App.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/Sources/App.swift rename to Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/MyProject/Sources/App.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/SubModule/Package.swift b/Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/MyProject/SubModule/Package.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/SubModule/Package.swift rename to Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/MyProject/SubModule/Package.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/ThirdParty/Lottie/Private/Model/DotLottie/ZipFoundation/Archive+Writing.swift b/Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/MyProject/ThirdParty/Lottie/Private/Model/DotLottie/ZipFoundation/Archive+Writing.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/MyProject/ThirdParty/Lottie/Private/Model/DotLottie/ZipFoundation/Archive+Writing.swift rename to Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/MyProject/ThirdParty/Lottie/Private/Model/DotLottie/ZipFoundation/Archive+Writing.swift diff --git a/Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/ThirdParty/Library.swift b/Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/ThirdParty/Library.swift similarity index 100% rename from Tests/IntegrationTests/PathHierarchyFixtures/scenario7_wildcard_regression_5953/ThirdParty/Library.swift rename to Tests/IntegrationTests/Resources/_6_wildcards_from_nested_folder/ThirdParty/Library.swift diff --git a/Tests/IntegrationTests/default_rule_configurations.yml b/Tests/IntegrationTests/Resources/default_rule_configurations.yml similarity index 100% rename from Tests/IntegrationTests/default_rule_configurations.yml rename to Tests/IntegrationTests/Resources/default_rule_configurations.yml From 607d7011c27e44415461e48ea74bc4d0b488ba45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danny=20M=C3=B6sch?= Date: Mon, 17 Nov 2025 14:13:57 +0100 Subject: [PATCH 5/5] Collect files with directory enumerator --- .../Configuration+CommandLine.swift | 7 +- .../Configuration+LintableFiles.swift | 88 ++++++------- .../Extensions/FileManager+SwiftLint.swift | 54 ++++++-- .../ConfigurationTests+Mock.swift | 4 + .../ConfigurationTests.swift | 122 +++++++++--------- .../directory/ExcludedFile.swift | 4 + .../ExclusionTests/directory/File1.swift | 4 + .../ExclusionTests/directory/File2.swift | 4 + .../directory/excluded/Excluded.swift | 4 + Tests/FrameworkTests/LinterCacheTests.swift | 4 +- 10 files changed, 174 insertions(+), 121 deletions(-) create mode 100644 Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/ExcludedFile.swift create mode 100644 Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/File1.swift create mode 100644 Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/File2.swift create mode 100644 Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/excluded/Excluded.swift diff --git a/Source/SwiftLintFramework/Configuration+CommandLine.swift b/Source/SwiftLintFramework/Configuration+CommandLine.swift index 4c65708067..512c833d94 100644 --- a/Source/SwiftLintFramework/Configuration+CommandLine.swift +++ b/Source/SwiftLintFramework/Configuration+CommandLine.swift @@ -253,11 +253,8 @@ extension Configuration { return files } let scriptInputPaths = files.compactMap(\.path) - return ( - visitor.options.useExcludingByPrefix - ? filterExcludedPathsByPrefix(in: scriptInputPaths) - : filterExcludedPaths(in: scriptInputPaths) - ).map(SwiftLintFile.init(pathDeferringReading:)) + return filteredPaths(in: scriptInputPaths, excludeByPrefix: visitor.options.useExcludingByPrefix) + .map(SwiftLintFile.init(pathDeferringReading:)) } if !options.quiet { let filesInfo: String diff --git a/Source/SwiftLintFramework/Configuration/Configuration+LintableFiles.swift b/Source/SwiftLintFramework/Configuration/Configuration+LintableFiles.swift index 8dc652dbf9..2102cd55f9 100644 --- a/Source/SwiftLintFramework/Configuration/Configuration+LintableFiles.swift +++ b/Source/SwiftLintFramework/Configuration/Configuration+LintableFiles.swift @@ -32,65 +32,61 @@ extension Configuration { /// - parameter fileManager: The lintable file manager to use to search for lintable files. /// /// - returns: Paths for files to lint. - internal func lintablePaths( + func lintablePaths( inPath path: String, forceExclude: Bool, excludeByPrefix: Bool, fileManager: some LintableFileManager = FileManager.default ) -> [String] { + let excluder = createExcluder(excludeByPrefix: excludeByPrefix) + + // Handle single file path. if fileManager.isFile(atPath: path) { - let file = fileManager.filesToLint(inPath: path, rootDirectory: nil) - if forceExclude { - return excludeByPrefix - ? filterExcludedPathsByPrefix(in: file) - : filterExcludedPaths(in: file) - } - // If path is a file and we're not forcing excludes, skip filtering with excluded/included paths - return file + return fileManager.filesToLint( + inPath: path, + rootDirectory: nil, + excluder: forceExclude ? excluder : .noExclusion + ) } - let pathsForPath = includedPaths.isEmpty ? fileManager.filesToLint(inPath: path, rootDirectory: nil) : [] - let includedPaths = self.includedPaths - .flatMap(Glob.resolveGlob) - .parallelFlatMap { fileManager.filesToLint(inPath: $0, rootDirectory: rootDirectory) } + // With no included paths, we lint everything in the given path. + if includedPaths.isEmpty { + return fileManager.filesToLint( + inPath: path, + rootDirectory: nil, + excluder: excluder + ) + } - return excludeByPrefix - ? filterExcludedPathsByPrefix(in: pathsForPath + includedPaths) - : filterExcludedPaths(in: pathsForPath + includedPaths) + // With included paths, we only lint those paths (after resolving globs). + return includedPaths + .flatMap(Glob.resolveGlob) + .parallelFlatMap { + fileManager.filesToLint( + inPath: $0, + rootDirectory: rootDirectory, + excluder: excluder + ) + } } - /// Returns an array of file paths after removing the excluded paths as defined by this configuration. - /// - /// - parameter paths: The input paths to filter. - /// - /// - returns: The input paths after removing the excluded paths. - public func filterExcludedPaths(in paths: [String]) -> [String] { - #if os(Linux) - let result = NSMutableOrderedSet(capacity: paths.count) - result.addObjects(from: paths) - #else - let result = NSOrderedSet(array: paths) - #endif - let exclusionPatterns = self.excludedPaths.flatMap { - Glob.createFilenameMatchers(root: rootDirectory, pattern: $0) - } - return result.array - .parallelCompactMap { exclusionPatterns.anyMatch(filename: $0 as! String) ? nil : $0 as? String } - // swiftlint:disable:previous force_cast + func filteredPaths(in paths: [String], excludeByPrefix: Bool) -> [String] { + let excluder = createExcluder(excludeByPrefix: excludeByPrefix) + return paths.filter { !excluder.excludes(path: $0) } } - /// Returns the file paths that are excluded by this configuration using filtering by absolute path prefix. - /// - /// For cases when excluded directories contain many lintable files (e. g. Pods) it works faster than default - /// algorithm `filterExcludedPaths`. - /// - /// - returns: The input paths after removing the excluded paths. - public func filterExcludedPathsByPrefix(in paths: [String]) -> [String] { - let excludedPaths = self.excludedPaths - .parallelFlatMap { Glob.resolveGlob($0) } - .map { $0.absolutePathStandardized() } - return paths.filter { path in - !excludedPaths.contains { path.hasPrefix($0) } + private func createExcluder(excludeByPrefix: Bool) -> Excluder { + if excludeByPrefix { + return .byPrefix( + prefixes: self.excludedPaths + .flatMap { Glob.resolveGlob($0) } + .map { $0.absolutePathStandardized() } + ) } + return .matching( + matchers: self.excludedPaths.flatMap { + Glob.createFilenameMatchers(root: rootDirectory, pattern: $0) + } + ) } } diff --git a/Source/SwiftLintFramework/Extensions/FileManager+SwiftLint.swift b/Source/SwiftLintFramework/Extensions/FileManager+SwiftLint.swift index 31280a08ed..d8c3669398 100644 --- a/Source/SwiftLintFramework/Extensions/FileManager+SwiftLint.swift +++ b/Source/SwiftLintFramework/Extensions/FileManager+SwiftLint.swift @@ -1,17 +1,19 @@ +import FilenameMatcher import Foundation import SourceKittenFramework /// An interface for enumerating files that can be linted by SwiftLint. public protocol LintableFileManager { /// Returns all files that can be linted in the specified path. If the path is relative, it will be appended to the - /// specified root path, or currentt working directory if no root directory is specified. + /// specified root path, or current working directory if no root directory is specified. /// /// - parameter path: The path in which lintable files should be found. /// - parameter rootDirectory: The parent directory for the specified path. If none is provided, the current working /// directory will be used. + /// - parameter excluder: The excluder used to filter out files that should not be linted. /// /// - returns: Files to lint. - func filesToLint(inPath path: String, rootDirectory: String?) -> [String] + func filesToLint(inPath path: String, rootDirectory: String?, excluder: Excluder) -> [String] /// Returns the date when the file at the specified path was last modified. Returns `nil` if the file cannot be /// found or its last modification date cannot be determined. @@ -29,22 +31,56 @@ public protocol LintableFileManager { func isFile(atPath path: String) -> Bool } +/// An excluder for filtering out files that should not be linted. +public enum Excluder { + /// Full matching excluder using filename matchers. + case matching(matchers: [FilenameMatcher]) + /// Prefix-based excluder using path prefixes. + case byPrefix(prefixes: [String]) + /// An excluder that does not exclude any files. + case noExclusion + + func excludes(path: String) -> Bool { + switch self { + case let .matching(matchers): + matchers.contains(where: { $0.match(filename: path) }) + case let .byPrefix(prefixes): + prefixes.contains(where: { path.hasPrefix($0) }) + case .noExclusion: + false + } + } +} + extension FileManager: LintableFileManager { - public func filesToLint(inPath path: String, rootDirectory: String? = nil) -> [String] { + public func filesToLint(inPath path: String, + rootDirectory: String? = nil, + excluder: Excluder) -> [String] { let absolutePath = path.bridge() .absolutePathRepresentation(rootDirectory: rootDirectory ?? currentDirectoryPath).bridge() .standardizingPath - // if path is a file, it won't be returned in `enumerator(atPath:)` + // If path is a file, it won't be returned in `enumerator(atPath:)`. if absolutePath.bridge().isSwiftFile(), absolutePath.isFile { - return [absolutePath] + return excluder.excludes(path: absolutePath) ? [] : [absolutePath] + } + + guard let enumerator = enumerator(atPath: absolutePath) else { + return [] } - return subpaths(atPath: absolutePath)?.parallelCompactMap { element -> String? in - guard element.bridge().isSwiftFile() else { return nil } + var files = [String]() + while let element = enumerator.nextObject() as? String { let absoluteElementPath = absolutePath.bridge().appendingPathComponent(element) - return absoluteElementPath.isFile ? absoluteElementPath : nil - } ?? [] + if absoluteElementPath.bridge().isSwiftFile(), absoluteElementPath.isFile { + if !excluder.excludes(path: absoluteElementPath) { + files.append(absoluteElementPath) + } + } else if excluder.excludes(path: absoluteElementPath) { + enumerator.skipDescendants() + } + } + return files } public func modificationDate(forFileAtPath path: String) -> Date? { diff --git a/Tests/FileSystemAccessTests/ConfigurationTests+Mock.swift b/Tests/FileSystemAccessTests/ConfigurationTests+Mock.swift index e4b6cfce41..e4a7fc5e15 100644 --- a/Tests/FileSystemAccessTests/ConfigurationTests+Mock.swift +++ b/Tests/FileSystemAccessTests/ConfigurationTests+Mock.swift @@ -33,6 +33,10 @@ internal extension ConfigurationTests { static var remoteConfigLocalRef: String { level0.stringByAppendingPathComponent("RemoteConfig/LocalRef") } static var remoteConfigCycle: String { level0.stringByAppendingPathComponent("RemoteConfig/Cycle") } static var emptyFolder: String { level0.stringByAppendingPathComponent("EmptyFolder") } + + static var exclusionTests: String { testResourcesPath.stringByAppendingPathComponent("ExclusionTests") } + static var directory: String { exclusionTests.stringByAppendingPathComponent("directory") } + static var directoryExcluded: String { directory.stringByAppendingPathComponent("excluded") } } // MARK: YAML File Paths diff --git a/Tests/FileSystemAccessTests/ConfigurationTests.swift b/Tests/FileSystemAccessTests/ConfigurationTests.swift index 7b3601571e..67adc66f28 100644 --- a/Tests/FileSystemAccessTests/ConfigurationTests.swift +++ b/Tests/FileSystemAccessTests/ConfigurationTests.swift @@ -281,85 +281,75 @@ final class ConfigurationTests: SwiftLintTestCase { XCTAssertEqual(actualExcludedPath, desiredExcludedPath) } - private class TestFileManager: LintableFileManager { - func filesToLint(inPath path: String, rootDirectory _: String? = nil) -> [String] { - var filesToLint: [String] = [] - switch path { - case "directory": filesToLint = [ - "directory/File1.swift", - "directory/File2.swift", - "directory/excluded/Excluded.swift", - "directory/ExcludedFile.swift", - ] - case "directory/excluded": filesToLint = ["directory/excluded/Excluded.swift"] - case "directory/ExcludedFile.swift": filesToLint = ["directory/ExcludedFile.swift"] - default: XCTFail("Should not be called with path \(path)") - } - return filesToLint.absolutePathsStandardized() - } - - func modificationDate(forFileAtPath _: String) -> Date? { - nil - } - - func isFile(atPath path: String) -> Bool { - path.hasSuffix(".swift") - } - } - func testExcludedPaths() { - let fileManager = TestFileManager() + XCTAssert(FileManager.default.changeCurrentDirectoryPath(Mock.Dir.exclusionTests)) let configuration = Configuration( includedPaths: ["directory"], excludedPaths: ["directory/excluded", "directory/ExcludedFile.swift"] ) - let paths = configuration.lintablePaths(inPath: "", - forceExclude: false, - excludeByPrefix: false, - fileManager: fileManager) - XCTAssertEqual(["directory/File1.swift", "directory/File2.swift"].absolutePathsStandardized(), paths) + let paths = configuration.lintablePaths( + inPath: "", + forceExclude: false, + excludeByPrefix: false + ) + + assertEqual(["directory/File1.swift", "directory/File2.swift"], paths) } func testForceExcludesFile() { - let fileManager = TestFileManager() + XCTAssert(FileManager.default.changeCurrentDirectoryPath(Mock.Dir.exclusionTests)) let configuration = Configuration(excludedPaths: ["directory/ExcludedFile.swift"]) - let paths = configuration.lintablePaths(inPath: "directory/ExcludedFile.swift", - forceExclude: true, - excludeByPrefix: false, - fileManager: fileManager) - XCTAssertEqual([], paths) + + let paths = configuration.lintablePaths( + inPath: "directory/ExcludedFile.swift", + forceExclude: true, + excludeByPrefix: false + ) + + XCTAssert(paths.isEmpty) } func testForceExcludesFileNotPresentInExcluded() { - let fileManager = TestFileManager() - let configuration = Configuration(includedPaths: ["directory"], - excludedPaths: ["directory/ExcludedFile.swift", "directory/excluded"]) - let paths = configuration.lintablePaths(inPath: "", - forceExclude: true, - excludeByPrefix: false, - fileManager: fileManager) - XCTAssertEqual(["directory/File1.swift", "directory/File2.swift"].absolutePathsStandardized(), paths) + XCTAssert(FileManager.default.changeCurrentDirectoryPath(Mock.Dir.exclusionTests)) + let configuration = Configuration( + includedPaths: ["directory"], + excludedPaths: ["directory/ExcludedFile.swift", "directory/excluded"] + ) + + let paths = configuration.lintablePaths( + inPath: "", + forceExclude: true, + excludeByPrefix: false + ) + + assertEqual(["directory/File1.swift", "directory/File2.swift"], paths) } func testForceExcludesDirectory() { - let fileManager = TestFileManager() - let configuration = Configuration(excludedPaths: ["directory/excluded", "directory/ExcludedFile.swift"]) - let paths = configuration.lintablePaths(inPath: "directory", - forceExclude: true, - excludeByPrefix: false, - fileManager: fileManager) - XCTAssertEqual(["directory/File1.swift", "directory/File2.swift"].absolutePathsStandardized(), paths) + XCTAssert(FileManager.default.changeCurrentDirectoryPath(Mock.Dir.exclusionTests)) + let configuration = Configuration(excludedPaths: ["directory/excluded"]) + + let paths = configuration.lintablePaths( + inPath: "directory", + forceExclude: true, + excludeByPrefix: false + ) + + assertEqual(["directory/File1.swift", "directory/File2.swift", "directory/ExcludedFile.swift"], paths) } func testForceExcludesDirectoryThatIsNotInExcludedButHasChildrenThatAre() { - let fileManager = TestFileManager() - let configuration = Configuration(excludedPaths: ["directory/excluded", "directory/ExcludedFile.swift"]) - let paths = configuration.lintablePaths(inPath: "directory", - forceExclude: true, - excludeByPrefix: false, - fileManager: fileManager) - XCTAssertEqual(["directory/File1.swift", "directory/File2.swift"].absolutePathsStandardized(), paths) + XCTAssert(FileManager.default.changeCurrentDirectoryPath(Mock.Dir.exclusionTests)) + let configuration = Configuration(excludedPaths: ["directory/ExcludedFile.swift"]) + + let paths = configuration.lintablePaths( + inPath: "directory", + forceExclude: true, + excludeByPrefix: false + ) + + assertEqual(["directory/File1.swift", "directory/File2.swift", "directory/excluded/Excluded.swift"], paths) } func testLintablePaths() { @@ -621,6 +611,18 @@ extension ConfigurationTests { XCTAssertEqual(configuration1.cachePath, "cache/path/1") XCTAssertEqual(configuration2.cachePath, "cache/path/1") } + + private func assertEqual(_ relativeExpectedPaths: [String], + _ actualPaths: [String], + file: StaticString = #filePath, + line: UInt = #line) { + XCTAssertEqual( + relativeExpectedPaths.absolutePathsStandardized().sorted(), + actualPaths.sorted(), + file: file, + line: line + ) + } } private extension Sequence where Element == String { diff --git a/Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/ExcludedFile.swift b/Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/ExcludedFile.swift new file mode 100644 index 0000000000..f9f7f1e44a --- /dev/null +++ b/Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/ExcludedFile.swift @@ -0,0 +1,4 @@ +// Excluded test file +func excludedFunction() { + print("Excluded File") +} diff --git a/Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/File1.swift b/Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/File1.swift new file mode 100644 index 0000000000..cbd6604573 --- /dev/null +++ b/Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/File1.swift @@ -0,0 +1,4 @@ +// Test file 1 +func testFunction1() { + print("File 1") +} diff --git a/Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/File2.swift b/Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/File2.swift new file mode 100644 index 0000000000..1cf76f81e1 --- /dev/null +++ b/Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/File2.swift @@ -0,0 +1,4 @@ +// Test file 2 +func testFunction2() { + print("File 2") +} diff --git a/Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/excluded/Excluded.swift b/Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/excluded/Excluded.swift new file mode 100644 index 0000000000..fc575e5367 --- /dev/null +++ b/Tests/FileSystemAccessTests/Resources/ExclusionTests/directory/excluded/Excluded.swift @@ -0,0 +1,4 @@ +// Excluded directory test file +func excludedDirectoryFunction() { + print("Excluded in directory") +} diff --git a/Tests/FrameworkTests/LinterCacheTests.swift b/Tests/FrameworkTests/LinterCacheTests.swift index 66c2cb6bda..de1c4c3fcd 100644 --- a/Tests/FrameworkTests/LinterCacheTests.swift +++ b/Tests/FrameworkTests/LinterCacheTests.swift @@ -54,7 +54,9 @@ private struct CacheTestHelper { } private class TestFileManager: LintableFileManager { - fileprivate func filesToLint(inPath _: String, rootDirectory _: String? = nil) -> [String] { + fileprivate func filesToLint(inPath _: String, + rootDirectory _: String? = nil, + excluder _: Excluder) -> [String] { [] }