-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add an experimental baseline feature (#5475)
- Loading branch information
1 parent
99a990d
commit 96db41c
Showing
11 changed files
with
551 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,182 @@ | ||
import Foundation | ||
|
||
private typealias BaselineViolations = [BaselineViolation] | ||
private typealias ViolationsPerFile = [String: BaselineViolations] | ||
private typealias ViolationsPerRule = [String: BaselineViolations] | ||
|
||
private struct BaselineViolation: Codable, Hashable { | ||
let violation: StyleViolation | ||
let text: String | ||
var key: String { text + violation.reason + violation.severity.rawValue } | ||
|
||
init(violation: StyleViolation, text: String) { | ||
let location = violation.location | ||
self.violation = violation.with(location: Location( | ||
// Within the baseline, we use relative paths, so that | ||
// comparisons are independent of the absolute path | ||
file: location.relativeFile, | ||
line: location.line, | ||
character: location.character) | ||
) | ||
self.text = text | ||
} | ||
} | ||
|
||
/// A set of violations that can be used to filter newly detected violations. | ||
public struct Baseline: Equatable { | ||
private let baseline: ViolationsPerFile | ||
private var sortedBaselineViolations: BaselineViolations { | ||
baseline.sorted(by: { $0.key < $1.key }).flatMap(\.value) | ||
} | ||
|
||
/// The stored violations. | ||
public var violations: [StyleViolation] { | ||
sortedBaselineViolations.violationsWithAbsolutePaths | ||
} | ||
|
||
/// Creates a `Baseline` from a saved file. | ||
/// | ||
/// - parameter fromPath: The path to read from. | ||
public init(fromPath path: String) throws { | ||
let data = try Data(contentsOf: URL(fileURLWithPath: path)) | ||
baseline = try JSONDecoder().decode(BaselineViolations.self, from: data).groupedByFile() | ||
} | ||
|
||
/// Creates a `Baseline` from a list of violations. | ||
/// | ||
/// - parameter violations: The violations for the baseline. | ||
public init(violations: [StyleViolation]) { | ||
baseline = BaselineViolations(violations).groupedByFile() | ||
} | ||
|
||
/// Writes a `Baseline` to disk in JSON format. | ||
/// | ||
/// - parameter toPath: The path to write to. | ||
public func write(toPath path: String) throws { | ||
let data = try JSONEncoder().encode(sortedBaselineViolations) | ||
try data.write(to: URL(fileURLWithPath: path)) | ||
} | ||
|
||
/// Filters out violations that are present in the `Baseline`. | ||
/// | ||
/// Assumes that all violations are from the same file. | ||
/// | ||
/// - parameter violations: The violations to filter. | ||
/// - Returns: The new violations. | ||
public func filter(_ violations: [StyleViolation]) -> [StyleViolation] { | ||
guard let firstViolation = violations.first, | ||
let baselineViolations = baseline[firstViolation.location.relativeFile ?? ""], | ||
baselineViolations.isNotEmpty else { | ||
return violations | ||
} | ||
|
||
let relativePathViolations = BaselineViolations(violations) | ||
if relativePathViolations == baselineViolations { | ||
return [] | ||
} | ||
|
||
let violationsByRuleIdentifier = relativePathViolations.groupedByRuleIdentifier( | ||
filteredBy: baselineViolations | ||
) | ||
let baselineViolationsByRuleIdentifier = baselineViolations.groupedByRuleIdentifier( | ||
filteredBy: relativePathViolations | ||
) | ||
|
||
var filteredViolations: Set<BaselineViolation> = [] | ||
|
||
for (ruleIdentifier, ruleViolations) in violationsByRuleIdentifier { | ||
guard | ||
let baselineViolations = baselineViolationsByRuleIdentifier[ruleIdentifier], | ||
baselineViolations.isNotEmpty else { | ||
filteredViolations.formUnion(ruleViolations) | ||
continue | ||
} | ||
|
||
let groupedRuleViolations = Dictionary(grouping: ruleViolations, by: \.key) | ||
let groupedBaselineViolations = Dictionary(grouping: baselineViolations, by: \.key) | ||
|
||
for (key, ruleViolations) in groupedRuleViolations { | ||
guard let baselineViolations = groupedBaselineViolations[key] else { | ||
filteredViolations.formUnion(ruleViolations) | ||
continue | ||
} | ||
if ruleViolations.count > baselineViolations.count { | ||
filteredViolations.formUnion(ruleViolations) | ||
} | ||
} | ||
} | ||
|
||
let violationsWithAbsolutePaths = Set(filteredViolations.violationsWithAbsolutePaths) | ||
return violations.filter { violationsWithAbsolutePaths.contains($0) } | ||
} | ||
|
||
/// Returns the violations that are present in another `Baseline`, but not in this one. | ||
/// | ||
/// The violations are filtered using the same algorithm as the `filter` method above. | ||
/// | ||
/// - parameter otherBaseline: The other `Baseline`. | ||
public func compare(_ otherBaseline: Baseline) -> [StyleViolation] { | ||
otherBaseline.baseline.flatMap { | ||
filter($1.violationsWithAbsolutePaths) | ||
} | ||
} | ||
} | ||
|
||
private struct LineCache { | ||
private var lines: [String: [String]] = [:] | ||
|
||
mutating func text(at location: Location) -> String { | ||
let line = (location.line ?? 0) - 1 | ||
if line > 0, let file = location.file, let content = cached(file: file), line < content.count { | ||
return content[line] | ||
} | ||
return "" | ||
} | ||
|
||
private mutating func cached(file: String) -> [String]? { | ||
if let fileLines = lines[file] { | ||
return fileLines | ||
} | ||
if let fileLines = SwiftLintFile(path: file)?.lines.map(\.content) { | ||
lines[file] = fileLines | ||
return fileLines | ||
} | ||
return nil | ||
} | ||
} | ||
|
||
private extension Sequence where Element == BaselineViolation { | ||
init(_ violations: [StyleViolation]) where Self == BaselineViolations { | ||
var lineCache = LineCache() | ||
self = violations.map { $0.baselineViolation(text: lineCache.text(at: $0.location)) } | ||
} | ||
|
||
var violationsWithAbsolutePaths: [StyleViolation] { | ||
map { $0.violation.withAbsolutePath } | ||
} | ||
|
||
func groupedByFile() -> ViolationsPerFile { | ||
Dictionary(grouping: self) { $0.violation.location.relativeFile ?? "" } | ||
} | ||
|
||
func groupedByRuleIdentifier(filteredBy existingViolations: [BaselineViolation] = []) -> ViolationsPerRule { | ||
Dictionary(grouping: Set(self).subtracting(existingViolations), by: \.violation.ruleIdentifier) | ||
} | ||
} | ||
|
||
private extension StyleViolation { | ||
var withAbsolutePath: StyleViolation { | ||
let absolutePath: String? = | ||
if let relativePath = location.file { | ||
FileManager.default.currentDirectoryPath + "/" + relativePath | ||
} else { | ||
nil | ||
} | ||
let newLocation = Location(file: absolutePath, line: location.line, character: location.character) | ||
return with(location: newLocation) | ||
} | ||
|
||
func baselineViolation(text: String = "") -> BaselineViolation { | ||
BaselineViolation(violation: self, text: text) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
import ArgumentParser | ||
import Foundation | ||
import SwiftLintFramework | ||
|
||
extension SwiftLint { | ||
struct Baseline: ParsableCommand { | ||
static let configuration = CommandConfiguration( | ||
abstract: "Operations on existing baselines", | ||
subcommands: [Report.self, Compare.self], | ||
defaultSubcommand: Report.self | ||
) | ||
} | ||
|
||
private struct BaselineOptions: ParsableArguments { | ||
@Argument(help: "The path to the baseline file.") | ||
var baseline: String | ||
} | ||
|
||
private struct ReportingOptions: ParsableArguments { | ||
@Option( | ||
help: """ | ||
The reporter used to report violations. The 'summary' reporter can be useful to \ | ||
provide an overview. | ||
""" | ||
) | ||
var reporter: String? | ||
@Option(help: "The file where violations should be saved. Prints to stdout by default.") | ||
var output: String? | ||
} | ||
|
||
private struct Report: ParsableCommand { | ||
static let configuration = CommandConfiguration(abstract: "Reports the violations in a baseline.") | ||
|
||
@OptionGroup | ||
var options: BaselineOptions | ||
@OptionGroup | ||
var reportingOptions: ReportingOptions | ||
|
||
func run() throws { | ||
let savedBaseline = try SwiftLintCore.Baseline(fromPath: options.baseline) | ||
try report(savedBaseline.violations, using: reportingOptions.reporter, to: reportingOptions.output) | ||
ExitHelper.successfullyExit() | ||
} | ||
} | ||
|
||
private struct Compare: ParsableCommand { | ||
static let configuration = CommandConfiguration( | ||
abstract: "Reports the violations that are present in another baseline " + | ||
"but not in the original baseline." | ||
) | ||
|
||
@OptionGroup | ||
var options: BaselineOptions | ||
@Option( | ||
help: """ | ||
The path to a second baseline to compare against the baseline. Violations in \ | ||
the second baseline that are not present in the original baseline will be reported. | ||
""" | ||
) | ||
var otherBaseline: String | ||
@OptionGroup | ||
var reportingOptions: ReportingOptions | ||
|
||
func run() throws { | ||
let baseline = try SwiftLintCore.Baseline(fromPath: options.baseline) | ||
let otherBaseline = try SwiftLintCore.Baseline(fromPath: otherBaseline) | ||
try report(baseline.compare(otherBaseline), using: reportingOptions.reporter, to: reportingOptions.output) | ||
ExitHelper.successfullyExit() | ||
} | ||
} | ||
} | ||
|
||
private func report(_ violations: [StyleViolation], using reporterIdentifier: String?, to output: String?) throws { | ||
let reporter = reporterFrom(identifier: reporterIdentifier) | ||
let report = reporter.generateReport(violations) | ||
if report.isNotEmpty { | ||
if let output { | ||
let data = Data((report + "\n").utf8) | ||
do { | ||
try data.write(to: URL(fileURLWithPath: output)) | ||
} catch { | ||
Issue.fileNotWritable(path: output).print() | ||
} | ||
} else { | ||
queuedPrint(report) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.