Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .bazelrc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ build:macos --crosstool_top=@local_config_apple_cc//:toolchain
build:macos --host_crosstool_top=@local_config_apple_cc//:toolchain

build --macos_minimum_os=13.5 --host_macos_minimum_os=13.5
test --macos_minimum_os=14.8 --host_macos_minimum_os=14.8 # Swift Testing libraries are compiled for macOS 14+.
build --disk_cache=~/.bazel_cache
build --experimental_remote_cache_compression
build --remote_build_event_upload=minimal
Expand Down
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ For some rules, there are dedicated tests in `Tests/BuiltInRulesTests`. However,

The functionality of configurations does not need to be tested explicitly either. But all options should be verified in the provided examples with the `configuration:` parameter as well.

All changes need to pass `swift test --parallel` as well as running SwiftLint on itself. The command `swift run swiftlint` run in the root directory of the project does that.
All changes need to pass `swift test` as well as running SwiftLint on itself. The command `swift run swiftlint` run in the root directory of the project does that.
7 changes: 4 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jobs:
name: SPM, Linux, Swift 6.3
runs-on: ubuntu-24.04
container: swift:6.3-noble
timeout-minutes: 10
steps:
- &checkout-step
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
Expand All @@ -37,12 +38,11 @@ jobs:
spm_macos:
name: SPM, macOS ${{ matrix.macOS }}, Xcode ${{ matrix.xcode }}
runs-on: macos-${{ matrix.macOS }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- macOS: '14'
xcode: '16.2'
- macOS: '15'
xcode: '16.4'
- macOS: '26'
Expand All @@ -62,6 +62,7 @@ jobs:
spm_windows:
name: SPM, Windows ${{ matrix.windows-display-name }}
runs-on: windows-${{ matrix.windows-version }}
timeout-minutes: 30
env:
SWIFT_VERSION: swift-6.3.2-release
SWIFT_BUILD: 6.3.2-RELEASE
Expand Down Expand Up @@ -90,6 +91,6 @@ jobs:
restore-keys: windows-${{ matrix.windows-version }}-spm-${{ env.SWIFT_VERSION }}-${{ env.SWIFT_BUILD }}-
path: .build
- name: Run tests
run: swift test --parallel
run: swift test
- name: Lint codebase
run: swift run swiftlint lint --strict
9 changes: 9 additions & 0 deletions .swift-format
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"indentation" : {
"spaces" : 4
},
"lineLength": 120,
"rules" : {
"NoAccessLevelOnExtensionDeclaration": false
}
}
1 change: 1 addition & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ file_name:
- RuleConfigurationMacros.swift
- SwiftSyntax+SwiftLint.swift
- TestHelpers.swift
- Traits.swift
excluded_paths:
- Tests/GeneratedTests/GeneratedTests_\d\d\.swift
- Tests/FileSystemAccessTests/.+\.swift
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

### Breaking

* SwiftLint now requires a Swift 6.1 or higher compiler to build. The
[Swift Package Manager plugins](https://github.com/SimplyDanny/SwiftLintPlugins) continue
to work with all versions down to Swift 5.9.
[SimplyDanny](https://github.com/SimplyDanny)

* The `ignored_literal_argument_functions` option of the `force_unwrapping` rule now
uses the configured value as-is instead of always merging in the five built-in defaults
(`URL(string:)`, `NSURL(string:)`, `UIImage(named:)`, `NSImage(named:)`, `Data(hexString:)`).
Expand All @@ -20,6 +25,14 @@

### Enhancements

* Replace process-wide `FileManager.default.changeCurrentDirectoryPath` in tests with a
`@TaskLocal` `CurrentWorkingDirectory.url` that `URL.cwd` consults first. A new
`TestDirectory` Swift Testing trait (`.workingDirectory(_:)` / `.temporaryDirectory`)
sets the task-local value per test, enabling full parallel test execution without
serialization.
[dannysheridan](https://github.com/dannysheridan)
[#6048](https://github.com/realm/SwiftLint/pull/6048)

* SwiftLint can now be built and run on Windows. It is expected to work in the same way as
on other platforms. The only restrictions are missing support for `?[]` glob patterns in
include/exclude patterns and the requirement for `\n` as line ending in all linted files.
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ spm_build_plugins:
swift build -c release --product SwiftLintBuildToolPlugin

spm_test:
swift test --parallel --explicit-target-dependency-import-check warn -Xswiftc -DDISABLE_FOCUSED_EXAMPLES
swift test --explicit-target-dependency-import-check warn -Xswiftc -DDISABLE_FOCUSED_EXAMPLES

write_xcodebuild_log:
xcodebuild -scheme swiftlint clean build-for-testing -destination "platform=macOS" > xcodebuild.log
Expand Down Expand Up @@ -149,7 +149,7 @@ docker_image:
docker build --platform linux/amd64 --force-rm --tag swiftlint .

docker_test:
docker run -v `pwd`:`pwd` -w `pwd` --name swiftlint --rm swift:6.0-noble swift test --parallel
docker run -v `pwd`:`pwd` -w `pwd` --name swiftlint --rm swift:6.0-noble swift test

docker_htop:
docker run --platform linux/amd64 -it --rm --pid=container:swiftlint terencewestphal/htop || reset
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ private extension SwiftLintFile {
func index(compilerArguments: [String]) -> SourceKittenDictionary? {
path
.flatMap { path in
try? Request.index(file: path.filepath, arguments: compilerArguments).send()
try? Request.index(file: path.filepath, arguments: compilerArguments).sendIfNotDisabled()
}
.map(SourceKittenDictionary.init)
}
Expand Down
98 changes: 97 additions & 1 deletion Source/SwiftLintCore/Extensions/Request+SwiftLint.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,78 @@
import Foundation
import SourceKittenFramework

/// Tracks whether sourcekitd has wedged in the current process.
///
/// Under Swift Testing the suite runs as many concurrent tasks on a small cooperative executor.
/// Each linted file issues a synchronous, blocking sourcekitd request; if the daemon wedges — as it
/// does on the macOS CI runners — every concurrent caller blocks on it and the whole run hangs. To
/// bound that, ``Request/sendIfNotDisabled()`` runs each request with a timeout, and the first time
/// one times out this status latches so subsequent requests skip sourcekitd instead of each paying
/// the timeout. A healthy daemon answers immediately, so the latch never trips and behaviour is
/// unchanged. See PR #6048.
public enum SourceKitStatus {
nonisolated(unsafe) private static var timedOut = false
private static let lock = NSLock()

/// Test-only override scoped to the current task tree, letting tests force the unavailable state
/// deterministically and in isolation from other tests running in parallel.
@TaskLocal package static var forceUnavailableForTesting = false

/// Whether SourceKit requests should be skipped rather than issued.
public static var isUnavailable: Bool {
if forceUnavailableForTesting {
return true
}
lock.lock()
defer { lock.unlock() }
return timedOut
}

/// Records that a request timed out, latching sourcekitd as unavailable for the rest of the run.
static func recordTimeout() {
lock.lock()
defer { lock.unlock() }
timedOut = true
}
}

/// Thrown by ``Request/sendIfNotDisabled()`` when sourcekitd has already timed out, so that callers
/// skip the request instead of issuing another one that would block.
public struct SourceKitUnavailableError: Error, Equatable {}

/// Thrown when sourcekitd does not answer a request within the timeout.
public struct SourceKitRequestTimedOutError: Error, Equatable {
public let timeout: TimeInterval
}

private struct UncheckedSendableValue<Value>: @unchecked Sendable {
let value: Value
}

private final class SourceKitResponseBox: @unchecked Sendable {
private let lock = NSLock()
private var result: Result<[String: any SourceKitRepresentable], any Error>?

func store(_ result: Result<[String: any SourceKitRepresentable], any Error>) {
lock.lock()
defer { lock.unlock() }
self.result = result
}

func load() -> Result<[String: any SourceKitRepresentable], any Error>? {
lock.lock()
defer { lock.unlock() }
return result
}
}

public extension Request {
nonisolated(unsafe) static var disableSourceKitOverride = false

/// How long to wait for a single sourcekitd request before giving up and treating the daemon as
/// wedged.
static let sourceKitRequestTimeout: TimeInterval = 30

static var disableSourceKit: Bool {
#if SWIFTLINT_DISABLE_SOURCEKIT
// Compile-time
Expand Down Expand Up @@ -49,7 +118,34 @@ public extension Request {
guard !Self.disableSourceKit else {
queuedFatalError("SourceKit is disabled by configuration.")
}
return try send()
// Once a sourcekitd request has wedged, skip the rest. Re-issuing a blocking request for
// every file would otherwise starve the bounded test executor and hang the run. See PR #6048.
guard !SourceKitStatus.isUnavailable else {
throw SourceKitUnavailableError()
}
do {
return try sendWithTimeout(Self.sourceKitRequestTimeout)
} catch let error as SourceKitRequestTimedOutError {
SourceKitStatus.recordTimeout()
throw error
}
}

/// Runs `send()` on a background queue and waits up to `timeout` seconds for a response, so a
/// wedged sourcekitd request cannot block the calling (cooperative executor) thread forever.
private func sendWithTimeout(_ timeout: TimeInterval) throws -> [String: any SourceKitRepresentable] {
let request = UncheckedSendableValue(value: self)
let box = SourceKitResponseBox()
let semaphore = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .userInitiated).async {
box.store(Result { try request.value.send() })
semaphore.signal()
}

guard semaphore.wait(timeout: .now() + timeout) == .success, let result = box.load() else {
throw SourceKitRequestTimedOutError(timeout: timeout)
}
return try result.get()
}

static func cursorInfoWithoutSymbolGraph(file: String, offset: ByteCount, arguments: [String]) -> Request {
Expand Down
37 changes: 28 additions & 9 deletions Source/SwiftLintCore/Extensions/URL+SwiftLint.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,29 @@
import Foundation

/// A namespace for the task-local current working directory.
///
/// In test code, set this per-task via `CurrentWorkingDirectory.$url.withValue(someURL) { ... }`
/// (or use the `.workingDirectory(_:)` / `.temporaryDirectory` test traits) instead of mutating
/// the process-wide `FileManager.default.currentDirectoryPath`. This allows tests that depend on
/// the working directory to run in parallel without interfering with each other.
public enum CurrentWorkingDirectory {
/// The current working directory for the running task.
///
/// `nil` means "use the process-wide CWD" (`FileManager.default.currentDirectoryPath`).
@TaskLocal public static var url: URL?
}

public extension URL {
/// The current working directory.
///
/// Returns the task-local override set via `CurrentWorkingDirectory.$url.withValue(_:)` when
/// present, and falls back to the process-wide `FileManager.default.currentDirectoryPath`
/// otherwise. Use this instead of reading `FileManager.default.currentDirectoryPath` directly.
static var cwd: URL {
FileManager.default.currentDirectoryPath.url(directoryHint: .isDirectory)
if let url = CurrentWorkingDirectory.url {
return url
}
return URL(filePath: FileManager.default.currentDirectoryPath, directoryHint: .isDirectory)
}

var filepath: String {
Expand Down Expand Up @@ -72,14 +93,12 @@ public extension URL {

public extension String {
func url(relativeTo base: URL? = nil, directoryHint: URL.DirectoryHint = .inferFromPath) -> URL {
guard var base else {
return URL(filePath: self, directoryHint: directoryHint).standardizedFileURL
}
if base.isDirectory {
let lastComponent = base.lastPathComponent
base.deleteLastPathComponent()
base.append(path: lastComponent, directoryHint: .isDirectory)
var resolvedBase = base ?? URL.cwd
if resolvedBase.isDirectory {
let lastComponent = resolvedBase.lastPathComponent
resolvedBase.deleteLastPathComponent()
resolvedBase.append(path: lastComponent, directoryHint: .isDirectory)
}
return URL(filePath: self, directoryHint: directoryHint, relativeTo: base).standardizedFileURL
return URL(filePath: self, directoryHint: directoryHint, relativeTo: resolvedBase).standardizedFileURL
}
}
14 changes: 2 additions & 12 deletions Source/SwiftLintCore/Helpers/Macros.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Foundation

/// Macro to be attached to rule configurations. It generates the configuration parsing logic
/// automatically based on the defined `@ConfigurationElement`s.
@attached(
Expand Down Expand Up @@ -76,15 +78,3 @@ public macro SwiftSyntaxRule(foldExpressions: Bool = false,
module: "SwiftLintCoreMacros",
type: "SwiftSyntaxRule"
)

@attached(body)
macro TemporaryDirectory() = #externalMacro(
module: "SwiftLintCoreMacros",
type: "TemporaryDirectory"
)

@attached(body)
macro WorkingDirectory(path: String) = #externalMacro(
module: "SwiftLintCoreMacros",
type: "WorkingDirectory"
)
5 changes: 5 additions & 0 deletions Source/SwiftLintCore/Models/Example.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ public struct Example: Sendable {
public private(set) var testOnLinux: Bool
/// Whether the example should be tested on Windows
public private(set) var testOnWindows: Bool
/// The ID of the file in which the example was created
public private(set) var fileID: String
/// The path to the file where the example was created
public private(set) var file: StaticString
/// The line in the file where the example was created
Expand Down Expand Up @@ -59,6 +61,7 @@ public extension Example {
/// - testDisableCommand: Whether tests shall verify that the disabled rule (comment in the example) doesn't
/// trigger.
/// - testOnLinux: Whether the example should be tested on Linux.
/// - fileID: ID of the file in which the example is located.
/// - file: The path to the file where the example is located.
/// Defaults to the file where this initializer is called.
/// - line: The line in the file where the example is located.
Expand All @@ -71,6 +74,7 @@ public extension Example {
testDisableCommand: Bool = true,
testOnLinux: Bool = true,
testOnWindows: Bool = true,
fileID: String = #fileID,
file: StaticString = #filePath,
line: UInt = #line,
excludeFromDocumentation: Bool = false) {
Expand All @@ -79,6 +83,7 @@ public extension Example {
self.testMultiByteOffsets = testMultiByteOffsets
self.testOnLinux = testOnLinux
self.testOnWindows = testOnWindows
self.fileID = fileID
self.file = file
self.line = line
self.excludeFromDocumentation = excludeFromDocumentation
Expand Down
7 changes: 5 additions & 2 deletions Source/SwiftLintCore/Models/RuleRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@ public final class RuleRegistry: @unchecked Sendable {
///
/// - note: Adding registering more rules after this was first
/// accessed will not work.
public private(set) lazy var list = RuleList(rules: registeredRules)
public private(set) var list: RuleList! // swiftlint:disable:this implicitly_unwrapped_optional

private init() { /* To guarantee that this is singleton. */ }

/// Register rules.
///
/// - parameter rules: The rules to register.
public func register(rules: [any Rule.Type]) {
registeredRules.append(contentsOf: rules)
if list != nil {
queuedFatalError("Rules cannot be registered after the rule list has been accessed.")
}
list = RuleList(rules: rules)
}

/// Look up a rule for a given ID.
Expand Down
2 changes: 1 addition & 1 deletion Source/SwiftLintCoreMacros/DisabledWithoutSourceKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ enum DisabledWithoutSourceKit: ExtensionMacro {
try ExtensionDeclSyntax("""
\(raw: acl) extension \(type) {
private static let postMessage: Void = {
Issue.genericWarning(\(raw: message)).print()
SwiftLintCore.Issue.genericWarning(\(raw: message)).print()
}()

func notifyRuleDisabledOnce() {
Expand Down
Loading
Loading