Files

672 lines
27 KiB
Swift

import Foundation
import SourceKittenFramework
import SwiftSyntax
import XCTest
import SwiftLintFramework
#if os(Windows)
private struct PlatformInfo {
struct DefaultProperties {
let versionXCTest: String
let versionSwiftTesting: String
let swiftFlags: [String]?
}
let defaults: DefaultProperties
}
extension PlatformInfo.DefaultProperties: Decodable {
enum CodingKeys: String, CodingKey {
case versionXCTest = "XCTEST_VERSION"
case versionSwiftTesting = "SWIFT_TESTING_VERSION"
case swiftFlags = "SWIFTC_FLAGS"
}
}
extension PlatformInfo: Decodable {
enum CodingKeys: String, CodingKey {
case defaults = "DefaultProperties"
}
}
private let info: PlatformInfo = {
let sdk = URL(fileURLWithPath: sdkPath(), isDirectory: true)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Info.plist")
guard let data = try? Data(contentsOf: sdk),
let info = try? PropertyListDecoder().decode(PlatformInfo.self, from: data) else {
fatalError("invalid platform SDK - couldn't decode \(sdk.path)")
}
return info
}()
#endif
// swiftlint:disable file_length
private let violationMarker = ""
private let violationMarkerChar = violationMarker.first!
private extension SwiftLintFile {
static func testFile(withContents contents: String, persistToDisk: Bool = false) -> SwiftLintFile {
if persistToDisk {
let url = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("swift")
_ = try? contents.data(using: .utf8)!.write(to: url)
return SwiftLintFile(path: url.path, isTestFile: true)!
}
return SwiftLintFile(contents: contents, isTestFile: true)
}
func makeCompilerArguments() -> [String] {
let sdk = sdkPath()
let frameworks = URL(fileURLWithPath: sdk, isDirectory: true)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Library")
.appendingPathComponent("Frameworks")
.path
let arguments = [
"-F", frameworks,
"-sdk", sdk,
"-Xfrontend", "-enable-objc-interop",
"-j4",
path!,
]
#if os(Windows)
let XCTestPath = URL(fileURLWithPath: sdk, isDirectory: true)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Library")
.appendingPathComponent("XCTest-\(info.defaults.versionXCTest)")
.appendingPathComponent("usr")
.appendingPathComponent("lib")
.appendingPathComponent("swift")
.appendingPathComponent("windows")
.path
return ["-I", XCTestPath] + arguments
#else
return arguments
#endif
}
}
public extension String {
func stringByAppendingPathComponent(_ pathComponent: String) -> String {
URL(fileURLWithPath: self).appendingPathComponent(pathComponent).filepath
}
}
public let allRuleIdentifiers = Set(RuleRegistry.shared.list.list.keys)
public extension Configuration {
func applyingConfiguration(from example: Example) -> Configuration {
guard let exampleConfiguration = example.configuration,
case let .onlyConfiguration(onlyRules) = rulesMode,
let firstRule = (onlyRules.first { $0 != "superfluous_disable_command" }),
case let configDict: [_: any Sendable] = ["only_rules": onlyRules, firstRule: exampleConfiguration],
let typedConfiguration = try? Configuration(dict: configDict) else { return self }
return merged(withChild: typedConfiguration, rootDirectory: rootDirectory)
}
}
public func violations(_ example: Example,
config inputConfig: Configuration = Configuration.default,
requiresFileOnDisk: Bool = false) -> [StyleViolation] {
SwiftLintFile.clearCaches()
let config = inputConfig.applyingConfiguration(from: example)
let stringStrippingMarkers = example.removingViolationMarkers()
guard requiresFileOnDisk else {
let file = SwiftLintFile.testFile(withContents: stringStrippingMarkers.code)
let storage = RuleStorage()
let linter = Linter(file: file, configuration: config).collect(into: storage)
return linter.styleViolations(using: storage)
}
let file = SwiftLintFile.testFile(withContents: stringStrippingMarkers.code, persistToDisk: true)
let storage = RuleStorage()
let collector = Linter(file: file, configuration: config, compilerArguments: file.makeCompilerArguments())
let linter = collector.collect(into: storage)
return linter.styleViolations(using: storage).withoutFiles()
}
public extension Collection where Element == String {
func violations(config: Configuration = Configuration.default, requiresFileOnDisk: Bool = false)
-> [StyleViolation] {
map { SwiftLintFile.testFile(withContents: $0, persistToDisk: requiresFileOnDisk) }
.violations(config: config, requiresFileOnDisk: requiresFileOnDisk)
}
func corrections(config: Configuration = Configuration.default, requiresFileOnDisk: Bool = false) -> [String: Int] {
map { SwiftLintFile.testFile(withContents: $0, persistToDisk: requiresFileOnDisk) }
.corrections(config: config, requiresFileOnDisk: requiresFileOnDisk)
}
}
public extension Collection where Element: SwiftLintFile {
func violations(config: Configuration = Configuration.default, requiresFileOnDisk: Bool = false)
-> [StyleViolation] {
let storage = RuleStorage()
let violations = map({ file in
Linter(file: file, configuration: config,
compilerArguments: requiresFileOnDisk ? file.makeCompilerArguments() : [])
}).map({ linter in
linter.collect(into: storage)
}).flatMap({ linter in
linter.styleViolations(using: storage)
})
return requiresFileOnDisk ? violations.withoutFiles() : violations
}
func corrections(config: Configuration = Configuration.default, requiresFileOnDisk: Bool = false) -> [String: Int] {
let storage = RuleStorage()
var corrections = [String: Int]()
for file in self {
let linter = Linter(
file: file,
configuration: config,
compilerArguments: requiresFileOnDisk ? file.makeCompilerArguments() : []
)
let collectedLinter = linter.collect(into: storage)
for (ruleName, numberOfCorrections) in collectedLinter.correct(using: storage) {
corrections[ruleName] = numberOfCorrections
}
}
return corrections
}
}
private extension Collection where Element == StyleViolation {
func withoutFiles() -> [StyleViolation] {
map { violation in
let locationWithoutFile = Location(file: nil, line: violation.location.line,
character: violation.location.character)
return violation.with(location: locationWithoutFile)
}
}
}
public extension Collection where Element == Example {
/// Returns a dictionary with SwiftLint violation markers () removed from keys.
///
/// - returns: A new `Array`.
func removingViolationMarkers() -> [Element] {
map { $0.removingViolationMarkers() }
}
}
private func cleanedContentsAndMarkerOffsets(from contents: String) -> (String, [AbsolutePosition]) {
var markerOffsets = [AbsolutePosition]()
var cleanedContents = ""
cleanedContents.reserveCapacity(contents.count)
var offset = AbsolutePosition(utf8Offset: 0)
for char in contents {
if char == violationMarkerChar {
markerOffsets.append(offset)
} else {
cleanedContents.append(char)
offset = offset.advanced(by: char.utf8.count)
}
}
return (cleanedContents, markerOffsets)
}
private func render(violations: [StyleViolation], in contents: String) -> String {
var contents = StringView(contents).lines.map(\.content)
for violation in violations.sorted(by: { $0.location > $1.location }) {
guard let line = violation.location.line,
let character = violation.location.character else { continue }
let message = String(repeating: " ", count: character - 1) + "^ " + [
"\(violation.severity.rawValue): ",
"\(violation.ruleName) Violation: ",
violation.reason,
" (\(violation.ruleIdentifier))",
].joined()
if line >= contents.count {
contents.append(message)
} else {
contents.insert(message, at: line)
}
}
return """
```
\(contents.joined(separator: "\n"))
```
"""
}
private func render(locations: [Location], in contents: String) -> String {
var contents = StringView(contents).lines.map(\.content)
for location in locations.sorted(by: > ) {
guard let line = location.line, let character = location.character else { continue }
let content = NSMutableString(string: contents[line - 1])
content.insert("", at: character - 1)
contents[line - 1] = content.bridge()
}
return """
```
\(contents.joined(separator: "\n"))
```
"""
}
private extension Configuration {
func assertCorrection(_ before: Example, expected: Example) {
let (cleanedBefore, _) = cleanedContentsAndMarkerOffsets(from: before.code)
let file = SwiftLintFile.testFile(withContents: cleanedBefore, persistToDisk: true)
// expectedLocations are needed to create before call `correct()`
let includeCompilerArguments = rules.contains(where: { $0 is any AnalyzerRule })
let compilerArguments = includeCompilerArguments ? file.makeCompilerArguments() : []
let storage = RuleStorage()
let collector = Linter(file: file, configuration: self, compilerArguments: compilerArguments)
let linter = collector.collect(into: storage)
let corrections = linter.correct(using: storage)
XCTAssertGreaterThanOrEqual(
corrections.count,
before.code != expected.code ? 1 : 0,
#function + ".expectedLocationsEmpty",
file: before.file,
line: before.line
)
XCTAssertEqual(
file.contents,
expected.code,
#function + ".file contents",
file: before.file, line: before.line)
let path = file.path!
do {
let corrected = try String(contentsOfFile: path, encoding: .utf8)
XCTAssertEqual(
corrected,
expected.code,
#function + ".corrected file equals expected",
file: before.file, line: before.line)
} catch {
XCTFail(
"couldn't read file at path '\(path)': \(error)",
file: before.file, line: before.line)
}
}
}
private extension String {
func toStringLiteral() -> String {
"\"" + replacingOccurrences(of: "\n", with: "\\n") + "\""
}
}
public func makeConfig(_ ruleConfiguration: Any?,
_ identifier: String,
skipDisableCommandTests: Bool = false) -> Configuration? {
let superfluousDisableCommandRuleIdentifier = SuperfluousDisableCommandRule.identifier
let identifiers: Set<String> = skipDisableCommandTests ? [identifier]
: [identifier, superfluousDisableCommandRuleIdentifier]
if let ruleConfiguration, let ruleType = RuleRegistry.shared.rule(forID: identifier) {
// The caller has provided a custom configuration for the rule under test
return (try? ruleType.init(configuration: ruleConfiguration)).flatMap { configuredRule in
let rules = skipDisableCommandTests ? [configuredRule] : [configuredRule, SuperfluousDisableCommandRule()]
return Configuration(
rulesMode: .onlyConfiguration(identifiers),
allRulesWrapped: rules.map { ($0, false) }
)
}
}
return Configuration(rulesMode: .onlyConfiguration(identifiers))
}
private func testCorrection(_ correction: (Example, Example),
configuration: Configuration,
testMultiByteOffsets: Bool) {
#if os(Linux)
guard correction.0.testOnLinux else {
return
}
#endif
#if os(Windows)
guard correction.0.testOnWindows else {
return
}
#endif
var config = configuration
if let correctionConfiguration = correction.0.configuration,
case let .onlyConfiguration(onlyRules) = configuration.rulesMode,
let ruleToConfigure = (onlyRules.first { $0 != SuperfluousDisableCommandRule.identifier }),
case let configDict: [_: any Sendable] = ["only_rules": onlyRules, ruleToConfigure: correctionConfiguration],
let typedConfiguration = try? Configuration(dict: configDict) {
config = configuration.merged(withChild: typedConfiguration, rootDirectory: configuration.rootDirectory)
}
config.assertCorrection(correction.0, expected: correction.1)
if testMultiByteOffsets, correction.0.testMultiByteOffsets {
config.assertCorrection(addEmoji(correction.0), expected: addEmoji(correction.1))
}
}
private func addEmoji(_ example: Example) -> Example {
example.with(code: "/* 👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦 */\n\(example.code)")
}
private func addShebang(_ example: Example) -> Example {
example.with(code: "#!/usr/bin/env swift\n\(example.code)")
}
public extension XCTestCase {
var isRunningWithBazel: Bool { FileManager.default.currentDirectoryPath.contains("bazel-out") }
func verifyRule(_ ruleDescription: RuleDescription,
ruleConfiguration: Any? = nil,
commentDoesntViolate: Bool = true,
stringDoesntViolate: Bool = true,
skipCommentTests: Bool = false,
skipStringTests: Bool = false,
skipDisableCommandTests: Bool = false,
testMultiByteOffsets: Bool = true,
testShebang: Bool = true,
file: StaticString = #filePath,
line: UInt = #line) {
guard ruleDescription.minSwiftVersion <= .current else {
return
}
guard let config = makeConfig(
ruleConfiguration,
ruleDescription.identifier,
skipDisableCommandTests: skipDisableCommandTests) else {
XCTFail("Failed to create configuration", file: (file), line: line)
return
}
let disableCommands: [String]
if skipDisableCommandTests {
disableCommands = []
} else {
disableCommands = ruleDescription.allIdentifiers.map { "// swiftlint:disable \($0)\n" }
}
verifyLint(
ruleDescription,
config: config,
commentDoesntViolate: commentDoesntViolate,
stringDoesntViolate: stringDoesntViolate,
skipCommentTests: skipCommentTests,
skipStringTests: skipStringTests,
disableCommands: disableCommands,
testMultiByteOffsets: testMultiByteOffsets,
testShebang: testShebang,
file: file,
line: line
)
verifyCorrections(
ruleDescription,
config: config,
disableCommands: disableCommands,
testMultiByteOffsets: testMultiByteOffsets
)
}
// swiftlint:disable:next function_body_length
func verifyLint(_ ruleDescription: RuleDescription,
config: Configuration,
commentDoesntViolate: Bool = true,
stringDoesntViolate: Bool = true,
skipCommentTests: Bool = false,
skipStringTests: Bool = false,
disableCommands: [String] = [],
testMultiByteOffsets: Bool = true,
testShebang: Bool = true,
file: StaticString = #filePath,
line: UInt = #line) {
func verify(triggers: [Example], nonTriggers: [Example]) {
verifyExamples(triggers: triggers, nonTriggers: nonTriggers, configuration: config,
requiresFileOnDisk: ruleDescription.requiresFileOnDisk)
}
func makeViolations(_ example: Example) -> [StyleViolation] {
violations(example, config: config, requiresFileOnDisk: ruleDescription.requiresFileOnDisk)
}
let focusedRuleDescription = ruleDescription.focused()
let (triggers, nonTriggers) = (
focusedRuleDescription.triggeringExamples,
focusedRuleDescription.nonTriggeringExamples
)
verify(triggers: triggers, nonTriggers: nonTriggers)
if testMultiByteOffsets {
verify(triggers: triggers.filter(\.testMultiByteOffsets).map(addEmoji),
nonTriggers: nonTriggers.filter(\.testMultiByteOffsets).map(addEmoji))
}
if testShebang {
verify(triggers: triggers.filter(\.testMultiByteOffsets).map(addShebang),
nonTriggers: nonTriggers.filter(\.testMultiByteOffsets).map(addShebang))
}
// Comment doesn't violate
if !skipCommentTests {
let triggersToCheck = triggers.filter(\.testWrappingInComment)
XCTAssertEqual(
triggersToCheck.flatMap { makeViolations($0.with(code: "/*\n " + $0.code + "\n */")) }.count,
commentDoesntViolate ? 0 : triggersToCheck.count,
"Violation(s) still triggered when code was nested inside a comment",
file: (file), line: line
)
}
// String doesn't violate
if !skipStringTests {
let triggersToCheck = triggers.filter(\.testWrappingInString)
XCTAssertEqual(
triggersToCheck.flatMap({ makeViolations($0.with(code: $0.code.toStringLiteral())) }).count,
stringDoesntViolate ? 0 : triggersToCheck.count,
"Violation(s) still triggered when code was nested inside a string literal",
file: (file), line: line
)
}
// Disabled rule doesn't violate and disable command isn't superfluous
for command in disableCommands {
let disabledTriggers = triggers
.filter(\.testDisableCommand)
.map { $0.with(code: command + $0.code) }
for trigger in disabledTriggers {
let violationsPartitionedByType = makeViolations(trigger)
.partitioned { $0.ruleIdentifier == SuperfluousDisableCommandRule.identifier }
XCTAssert(violationsPartitionedByType.first.isEmpty,
"Violation(s) still triggered although rule was disabled",
file: trigger.file,
line: trigger.line)
XCTAssert(violationsPartitionedByType.second.isEmpty,
"Disable command was superfluous since no violations(s) triggered",
file: trigger.file,
line: trigger.line)
}
}
// Severity can be changed
let ruleType = RuleRegistry.shared.rule(forID: ruleDescription.identifier)
if ruleType?.init().configuration is (any SeverityBasedRuleConfiguration),
let example = triggers.first(where: { $0.configuration == nil }) {
let withWarning = Example(example.code, configuration: ["severity": "warning"])
XCTAssert(
violations(withWarning, config: config).allSatisfy { $0.severity == .warning },
"Violation severity cannot be changed to warning",
file: example.file,
line: 1
)
let withError = Example(example.code, configuration: ["severity": "error"])
XCTAssert(
violations(withError, config: config).allSatisfy { $0.severity == .error },
"Violation severity cannot be changed to error",
file: example.file,
line: 1
)
}
}
func verifyCorrections(_ ruleDescription: RuleDescription,
config: Configuration,
disableCommands: [String],
testMultiByteOffsets: Bool,
parserDiagnosticsDisabledForTests: Bool = true) {
let ruleDescription = ruleDescription.focused()
SwiftLintCore.$parserDiagnosticsDisabledForTests.withValue(parserDiagnosticsDisabledForTests) {
// corrections
ruleDescription.corrections.forEach {
testCorrection($0, configuration: config, testMultiByteOffsets: testMultiByteOffsets)
}
// make sure strings that don't trigger aren't corrected
ruleDescription.nonTriggeringExamples.forEach {
testCorrection(($0, $0), configuration: config, testMultiByteOffsets: testMultiByteOffsets)
}
// "disable" commands do not correct
ruleDescription.corrections.forEach { before, _ in
for command in disableCommands {
let beforeDisabled = command + before.code
let expectedCleaned = before.with(code: cleanedContentsAndMarkerOffsets(from: beforeDisabled).0)
config.assertCorrection(expectedCleaned, expected: expectedCleaned)
}
}
}
}
private func verifyExamples(triggers: [Example],
nonTriggers: [Example],
configuration config: Configuration,
requiresFileOnDisk: Bool) {
// Non-triggering examples don't violate
for nonTrigger in nonTriggers {
let unexpectedViolations = violations(nonTrigger, config: config,
requiresFileOnDisk: requiresFileOnDisk)
if unexpectedViolations.isEmpty { continue }
let nonTriggerWithViolations = render(violations: unexpectedViolations, in: nonTrigger.code)
XCTFail(
"nonTriggeringExample violated: \n\(nonTriggerWithViolations)",
file: nonTrigger.file,
line: nonTrigger.line)
}
// Triggering examples violate
for trigger in triggers {
let triggerViolations = violations(trigger, config: config,
requiresFileOnDisk: requiresFileOnDisk)
// Triggering examples with violation markers violate at the marker's location
let (cleanTrigger, markerOffsets) = cleanedContentsAndMarkerOffsets(from: trigger.code)
if markerOffsets.isEmpty {
if triggerViolations.isEmpty {
XCTFail(
"triggeringExample did not violate: \n```\n\(trigger.code)\n```",
file: trigger.file,
line: trigger.line)
}
continue
}
let file = SwiftLintFile.testFile(withContents: cleanTrigger)
let expectedLocations = markerOffsets.map { Location(file: file, position: $0) }
// Assert violations on unexpected location
let violationsAtUnexpectedLocation = triggerViolations
.filter { !expectedLocations.contains($0.location) }
if !violationsAtUnexpectedLocation.isEmpty {
XCTFail("triggeringExample violated at unexpected location: \n" +
"\(render(violations: violationsAtUnexpectedLocation, in: cleanTrigger))",
file: trigger.file,
line: trigger.line)
}
// Assert locations missing violation
let violatedLocations = triggerViolations.map(\.location)
let locationsWithoutViolation = expectedLocations
.filter { !violatedLocations.contains($0) }
if !locationsWithoutViolation.isEmpty {
XCTFail("triggeringExample did not violate at expected location: \n" +
"\(render(locations: locationsWithoutViolation, in: cleanTrigger))",
file: trigger.file,
line: trigger.line)
}
XCTAssertEqual(triggerViolations.count, expectedLocations.count,
file: trigger.file, line: trigger.line)
for (triggerViolation, expectedLocation) in zip(triggerViolations, expectedLocations) {
XCTAssertEqual(
triggerViolation.location, expectedLocation,
"'\(trigger)' violation didn't match expected location.",
file: trigger.file,
line: trigger.line)
}
}
}
// file and line parameters are first so we can use trailing closure syntax with the closure
func checkError<T: Error & Equatable>(
file: StaticString = #filePath,
line: UInt = #line,
_ error: T,
closure: () throws -> Void) {
do {
try closure()
XCTFail("No error caught", file: (file), line: line)
} catch let rError as T {
if error != rError {
XCTFail("Wrong error caught. Got \(rError) but was expecting \(error)", file: (file), line: line)
}
} catch {
XCTFail("Wrong error caught", file: (file), line: line)
}
}
}
private struct FocusedRuleDescription {
let nonTriggeringExamples: [Example]
let triggeringExamples: [Example]
let corrections: [Example: Example]
init(rule: RuleDescription) {
let nonTriggering = rule.nonTriggeringExamples.filter(\.isFocused)
let triggering = rule.triggeringExamples.filter(\.isFocused)
let corrections = rule.corrections.filter { key, value in key.isFocused || value.isFocused }
let anyFocused = nonTriggering.isNotEmpty || triggering.isNotEmpty || corrections.isNotEmpty
if anyFocused {
self.nonTriggeringExamples = nonTriggering
self.triggeringExamples = triggering
self.corrections = corrections
#if DISABLE_FOCUSED_EXAMPLES
(nonTriggering + triggering + corrections.values).forEach { example in
XCTFail("Focused examples are disabled", file: example.file, line: example.line)
}
#endif
} else {
self.nonTriggeringExamples = rule.nonTriggeringExamples
self.triggeringExamples = rule.triggeringExamples
self.corrections = rule.corrections
}
}
}
private extension RuleDescription {
func focused() -> FocusedRuleDescription {
FocusedRuleDescription(rule: self)
}
}
package extension [any Rule] {
var customRules: CustomRules? {
first(where: { $0 is CustomRules }) as? CustomRules
}
}