Files
SwiftLint/Source/SwiftLintFramework/LintOrAnalyzeCommand.swift
Danny Mösch 6c1ae86eb3 Use URL for file paths throughout the code base
# Conflicts:
#	Source/SwiftLintFramework/Extensions/FileManager+SwiftLint.swift
#	Tests/IntegrationTests/ConfigPathResolutionTests.swift
2026-01-24 22:13:56 +01:00

473 lines
18 KiB
Swift

#if os(macOS)
@preconcurrency import Darwin
#endif
import Dispatch
import Foundation
import SourceKittenFramework
// swiftlint:disable file_length
package enum LintOrAnalyzeMode {
case lint, analyze
package var imperative: String {
switch self {
case .lint:
return "lint"
case .analyze:
return "analyze"
}
}
package var verb: String {
switch self {
case .lint:
return "linting"
case .analyze:
return "analyzing"
}
}
}
package struct LintOrAnalyzeOptions {
let mode: LintOrAnalyzeMode
let paths: [URL]
let useSTDIN: Bool
let configurationFiles: [URL]
let strict: Bool
let lenient: Bool
let forceExclude: Bool
let useExcludingByPrefix: Bool
let useScriptInputFiles: Bool
let useScriptInputFileLists: Bool
let benchmark: Bool
let reporter: String?
let baseline: URL?
let writeBaseline: URL?
let workingDirectory: String?
let quiet: Bool
let output: URL?
let progress: Bool
let cachePath: String?
let ignoreCache: Bool
let enableAllRules: Bool
let onlyRule: [String]
let autocorrect: Bool
let format: Bool
let disableSourceKit: Bool
let compilerLogPath: String?
let compileCommands: String?
let checkForUpdates: Bool
package init(mode: LintOrAnalyzeMode,
paths: [URL],
useSTDIN: Bool,
configurationFiles: [URL],
strict: Bool,
lenient: Bool,
forceExclude: Bool,
useExcludingByPrefix: Bool,
useScriptInputFiles: Bool,
useScriptInputFileLists: Bool,
benchmark: Bool,
reporter: String?,
baseline: URL?,
writeBaseline: URL?,
workingDirectory: String?,
quiet: Bool,
output: URL?,
progress: Bool,
cachePath: String?,
ignoreCache: Bool,
enableAllRules: Bool,
onlyRule: [String],
autocorrect: Bool,
format: Bool,
disableSourceKit: Bool,
compilerLogPath: String?,
compileCommands: String?,
checkForUpdates: Bool) {
self.mode = mode
self.paths = paths
self.useSTDIN = useSTDIN
self.configurationFiles = configurationFiles
self.strict = strict
self.lenient = lenient
self.forceExclude = forceExclude
self.useExcludingByPrefix = useExcludingByPrefix
self.useScriptInputFiles = useScriptInputFiles
self.useScriptInputFileLists = useScriptInputFileLists
self.benchmark = benchmark
self.reporter = reporter
self.baseline = baseline
self.writeBaseline = writeBaseline
self.workingDirectory = workingDirectory
self.quiet = quiet
self.output = output
self.progress = progress
self.cachePath = cachePath
self.ignoreCache = ignoreCache
self.enableAllRules = enableAllRules
self.onlyRule = onlyRule
self.autocorrect = autocorrect
self.format = format
self.disableSourceKit = disableSourceKit
self.compilerLogPath = compilerLogPath
self.compileCommands = compileCommands
self.checkForUpdates = checkForUpdates
}
var verb: String {
autocorrect ? "correcting" : mode.verb
}
var capitalizedVerb: String {
verb.capitalized
}
}
package struct LintOrAnalyzeCommand {
package static func run(_ options: LintOrAnalyzeOptions) async throws {
Request.disableSourceKitOverride = options.mode == .lint && options.disableSourceKit
if let workingDirectory = options.workingDirectory {
if !FileManager.default.changeCurrentDirectoryPath(workingDirectory) {
throw SwiftLintError.usageError(
description: """
Could not change working directory to '\(workingDirectory)'. \
Make sure it exists and is accessible.
"""
)
}
}
try await Signposts.record(name: "LintOrAnalyzeCommand.run") {
try await options.autocorrect ? autocorrect(options) : lintOrAnalyze(options)
}
}
private static func lintOrAnalyze(_ options: LintOrAnalyzeOptions) async throws {
let builder = LintOrAnalyzeResultBuilder(options)
let files = try await collectViolations(builder: builder)
if let baselineOutputPath = options.writeBaseline ?? builder.configuration.writeBaseline {
try Baseline(violations: builder.unfilteredViolations).write(toPath: baselineOutputPath)
}
let numberOfSeriousViolations = try Signposts.record(name: "LintOrAnalyzeCommand.PostProcessViolations") {
try postProcessViolations(files: files, builder: builder)
}
if options.checkForUpdates || builder.configuration.checkForUpdates {
await UpdateChecker.checkForUpdates()
}
if numberOfSeriousViolations > 0 {
exit(2)
}
}
private static func collectViolations(builder: LintOrAnalyzeResultBuilder) async throws -> [SwiftLintFile] {
let options = builder.options
let visitorMutationQueue = DispatchQueue(label: "io.realm.swiftlint.lintVisitorMutation")
let baseline = try baseline(options, builder.configuration)
return try await builder.configuration.visitLintableFiles(options: options, cache: builder.cache,
storage: builder.storage) { linter in
let currentViolations: [StyleViolation]
if options.benchmark {
CustomRuleTimer.shared.activate()
let start = Date()
let (violationsBeforeLeniency, currentRuleTimes) = linter
.styleViolationsAndRuleTimes(using: builder.storage)
currentViolations = applyLeniency(
options: options,
strict: builder.configuration.strict,
lenient: builder.configuration.lenient,
violations: violationsBeforeLeniency
)
visitorMutationQueue.sync {
builder.fileBenchmark.record(file: linter.file, from: start)
currentRuleTimes.forEach { builder.ruleBenchmark.record(id: $0, time: $1) }
}
} else {
currentViolations = applyLeniency(
options: options,
strict: builder.configuration.strict,
lenient: builder.configuration.lenient,
violations: linter.styleViolations(using: builder.storage)
)
}
let filteredViolations = baseline?.filter(currentViolations) ?? currentViolations
visitorMutationQueue.sync {
builder.unfilteredViolations += currentViolations
builder.violations += filteredViolations
}
linter.file.invalidateCache()
builder.report(violations: filteredViolations, realtimeCondition: true)
}
}
private static func postProcessViolations(
files: [SwiftLintFile],
builder: LintOrAnalyzeResultBuilder
) throws -> Int {
let options = builder.options
let configuration = builder.configuration
if isWarningThresholdBroken(configuration: configuration, violations: builder.violations), !options.lenient {
builder.violations.append(
createThresholdViolation(threshold: configuration.warningThreshold!)
)
builder.report(violations: [builder.violations.last!], realtimeCondition: true)
}
builder.report(violations: builder.violations, realtimeCondition: false)
let numberOfSeriousViolations = builder.violations.filter({ $0.severity == .error }).count
if !options.quiet {
printStatus(violations: builder.violations, files: files, serious: numberOfSeriousViolations,
verb: options.verb)
}
if options.benchmark {
builder.fileBenchmark.save()
for (id, time) in CustomRuleTimer.shared.dump() {
builder.ruleBenchmark.record(id: id, time: time)
}
builder.ruleBenchmark.save()
if !options.quiet, let memoryUsage = memoryUsage() {
queuedPrintError(memoryUsage)
}
}
try builder.cache?.save()
return numberOfSeriousViolations
}
private static func baseline(_ options: LintOrAnalyzeOptions, _ configuration: Configuration) throws -> Baseline? {
if let baselinePath = options.baseline ?? configuration.baseline {
do {
return try Baseline(fromPath: baselinePath)
} catch {
Issue.baselineNotReadable(path: baselinePath).print()
if (error as? CocoaError)?.code != CocoaError.fileReadNoSuchFile ||
options.writeBaseline != options.baseline {
throw error
}
}
}
return nil
}
private static func printStatus(violations: [StyleViolation], files: [SwiftLintFile], serious: Int, verb: String) {
let pluralSuffix = { (collection: [Any]) -> String in
collection.count != 1 ? "s" : ""
}
queuedPrintError(
"Done \(verb)! Found \(violations.count) violation\(pluralSuffix(violations)), " +
"\(serious) serious in \(files.count) file\(pluralSuffix(files))."
)
}
private static func isWarningThresholdBroken(configuration: Configuration,
violations: [StyleViolation]) -> Bool {
guard let warningThreshold = configuration.warningThreshold else { return false }
let numberOfWarningViolations = violations.filter({ $0.severity == .warning }).count
return numberOfWarningViolations >= warningThreshold
}
private static func createThresholdViolation(threshold: Int) -> StyleViolation {
let description = RuleDescription(
identifier: "warning_threshold",
name: "Warning Threshold",
description: "Number of warnings thrown is above the threshold",
kind: .lint
)
return StyleViolation(
ruleDescription: description,
severity: .error,
location: Location(file: nil, line: 0, character: 0),
reason: "Number of warnings exceeded threshold of \(threshold).")
}
private static func applyLeniency(
options: LintOrAnalyzeOptions,
strict: Bool,
lenient: Bool,
violations: [StyleViolation]
) -> [StyleViolation] {
let leniency = options.leniency(strict: strict, lenient: lenient)
switch leniency {
case (false, false):
return violations
case (false, true):
return violations.map {
if $0.severity == .error {
return $0.with(severity: .warning)
}
return $0
}
case (true, false):
return violations.map {
if $0.severity == .warning {
return $0.with(severity: .error)
}
return $0
}
case (true, true):
queuedFatalError("Invalid command line or config options: 'strict' and 'lenient' are mutually exclusive.")
}
}
private static func autocorrect(_ options: LintOrAnalyzeOptions) async throws {
let storage = RuleStorage()
let configuration = Configuration(options: options)
let correctionsBuilder = CorrectionsBuilder()
let files = try await configuration
.visitLintableFiles(options: options, cache: nil, storage: storage) { linter in
if options.format {
switch configuration.indentation {
case .tabs:
linter.format(useTabs: true, indentWidth: 4)
case .spaces(let count):
linter.format(useTabs: false, indentWidth: count)
}
}
let corrections = linter.correct(using: storage)
if !corrections.isEmpty, !options.quiet {
if options.useSTDIN {
queuedPrint(linter.file.contents)
} else {
let corrections = corrections.map {
Correction(
ruleName: $0.0,
filePath: linter.file.path,
numberOfCorrections: $0.1
)
}
if options.progress {
await correctionsBuilder.append(corrections)
} else {
let correctionLogs = corrections.map(\.consoleDescription)
queuedPrint(correctionLogs.joined(separator: "\n"))
}
}
}
}
if !options.quiet {
if options.progress {
let corrections = await correctionsBuilder.corrections
if !corrections.isEmpty {
let correctionLogs = corrections.map(\.consoleDescription)
options.writeToOutput(correctionLogs.joined(separator: "\n"))
}
}
let pluralSuffix = { (collection: [Any]) -> String in
collection.count != 1 ? "s" : ""
}
queuedPrintError("Done correcting \(files.count) file\(pluralSuffix(files))!")
}
}
}
private class LintOrAnalyzeResultBuilder {
var fileBenchmark = Benchmark(name: "files")
var ruleBenchmark = Benchmark(name: "rules")
/// All detected violations, unfiltered by the baseline, if any.
var unfilteredViolations = [StyleViolation]()
/// The violations to be reported, possibly filtered by a baseline, plus any threshold violations.
var violations = [StyleViolation]()
let storage = RuleStorage()
let configuration: Configuration
let reporter: any Reporter.Type
let cache: LinterCache?
let options: LintOrAnalyzeOptions
init(_ options: LintOrAnalyzeOptions) {
let config = Signposts.record(name: "LintOrAnalyzeCommand.ParseConfiguration") {
Configuration(options: options)
}
configuration = config
reporter = reporterFrom(identifier: options.reporter ?? config.reporter)
if options.ignoreCache || ProcessInfo.processInfo.isLikelyXcodeCloudEnvironment {
cache = nil
} else {
cache = LinterCache(configuration: config)
}
self.options = options
if let outFile = options.output {
do {
try Data().write(to: outFile)
} catch {
Issue.fileNotWritable(path: outFile).print()
}
}
}
func report(violations: [StyleViolation], realtimeCondition: Bool) {
if (reporter.isRealtime && (!options.progress || options.output != nil)) == realtimeCondition {
let report = reporter.generateReport(violations)
if !report.isEmpty {
options.writeToOutput(report)
}
}
}
}
extension LintOrAnalyzeOptions {
fileprivate func writeToOutput(_ string: String) {
guard let outFile = output else {
queuedPrint(string)
return
}
do {
let fileUpdater = try FileHandle(forUpdating: outFile)
fileUpdater.seekToEndOfFile()
fileUpdater.write(Data((string + "\n").utf8))
fileUpdater.closeFile()
} catch {
Issue.fileNotWritable(path: outFile).print()
}
}
typealias Leniency = (strict: Bool, lenient: Bool)
// Config file settings can be overridden by either `--strict` or `--lenient` command line options.
func leniency(strict configurationStrict: Bool, lenient configurationLenient: Bool) -> Leniency {
let strict = strict || (configurationStrict && !lenient)
let lenient = lenient || (configurationLenient && !self.strict)
return Leniency(strict: strict, lenient: lenient)
}
}
private actor CorrectionsBuilder {
private(set) var corrections: [Correction] = []
func append(_ corrections: [Correction]) {
self.corrections.append(contentsOf: corrections)
}
}
private func memoryUsage() -> String? {
#if os(Linux) || os(Windows)
return nil
#else
var info = mach_task_basic_info()
let basicInfoCount = MemoryLayout<mach_task_basic_info>.stride / MemoryLayout<natural_t>.stride
var count = mach_msg_type_number_t(basicInfoCount)
let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: basicInfoCount) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
}
}
if kerr == KERN_SUCCESS {
let bytes = Measurement<UnitInformationStorage>(value: Double(info.resident_size), unit: .bytes)
let formatted = ByteCountFormatter().string(from: bytes)
return "Memory used: \(formatted)"
}
let errorMessage = String(cString: mach_error_string(kerr), encoding: .ascii)
return "Error with task_info(): \(errorMessage ?? "unknown")"
#endif
}