diff --git a/Sources/Frontend/Commands/ScanBehavior.swift b/Sources/Frontend/Commands/ScanBehavior.swift index 806ed4355..b64124c19 100644 --- a/Sources/Frontend/Commands/ScanBehavior.swift +++ b/Sources/Frontend/Commands/ScanBehavior.swift @@ -62,12 +62,30 @@ final class ScanBehavior { do { results = try block(project) let interval = logger.beginInterval("result:output") - let filteredResults = OutputDeclarationFilter().filter(results) + var filteredResults = OutputDeclarationFilter().filter(results) if configuration.autoRemove { try ScanResultRemover().remove(results: filteredResults) } + if let baselineOutputPath = configuration.writeBaseline { + try Baseline(scanResults: filteredResults).write(toPath: baselineOutputPath) + } + + if let baselinePath = configuration.baseline { + do { + let baseline = try Baseline(fromPath: baselinePath) + filteredResults = baseline.filter(filteredResults) + } catch CocoaError.fileReadNoSuchFile { + // print a warning + if configuration.writeBaseline != configuration.baseline { +// return .failure(.underlyingError(error)) + } + } catch { + return .failure(.underlyingError(error)) + } + } + let output = try configuration.outputFormat.formatter.init(configuration: configuration).format(filteredResults) if configuration.outputFormat.supportsAuxiliaryOutput { diff --git a/Sources/Frontend/Commands/ScanCommand.swift b/Sources/Frontend/Commands/ScanCommand.swift index e90b90bb4..688a3aa6d 100644 --- a/Sources/Frontend/Commands/ScanCommand.swift +++ b/Sources/Frontend/Commands/ScanCommand.swift @@ -123,6 +123,12 @@ struct ScanCommand: FrontendCommand { @Option(help: "JSON package manifest path (obtained using `swift package describe --type json` or manually)") var jsonPackageManifestPath: String? + @Option(help: "The path to a baseline file, which will be used to filter out detected violations.") + var baseline: String? + + @Option(help: "The path to save detected violations to as a new baseline.") + var writeBaseline: String? + private static let defaultConfiguration = Configuration() func run() throws { @@ -174,6 +180,8 @@ struct ScanCommand: FrontendCommand { configuration.apply(\.$retainCodableProperties, retainCodableProperties) configuration.apply(\.$retainEncodableProperties, retainEncodableProperties) configuration.apply(\.$jsonPackageManifestPath, jsonPackageManifestPath) + configuration.apply(\.$baseline, baseline) + configuration.apply(\.$writeBaseline, writeBaseline) try scanBehavior.main { project in try Scan().perform(project: project) diff --git a/Sources/PeripheryKit/Baseline.swift b/Sources/PeripheryKit/Baseline.swift new file mode 100644 index 000000000..5b67d3fa2 --- /dev/null +++ b/Sources/PeripheryKit/Baseline.swift @@ -0,0 +1,289 @@ +import Foundation +import SystemPackage + +private typealias BaselineResults = [BaselineResult] +private typealias ResultsPerFile = [String: BaselineResults] +private typealias ResultsPerKind = [String: BaselineResults] + +private struct BaselineResult: Codable, Hashable, Comparable { + let scanResult: ScanResult + let text: String + var key: String { text + scanResult.declaration.kind.rawValue } + + init(scanResult: ScanResult, text: String) { + self.scanResult = scanResult.withRelativeLocation() + self.text = text + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.scanResult == rhs.scanResult && lhs.text == rhs.text + } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.scanResult.declaration.location == rhs.scanResult.declaration.location + ? lhs.scanResult.declaration.kind.rawValue < rhs.scanResult.declaration.kind.rawValue + : lhs.scanResult.declaration.location < rhs.scanResult.declaration.location + } +} + +/// A set of scan results that can be used to filter newly detected results. +public struct Baseline: Equatable { + private let baseline: ResultsPerFile + private var sortedBaselineResults: BaselineResults { + baseline.flatMap(\.value).sorted() + } + + /// The stored scan results. + public var scanResults: [ScanResult] { + sortedBaselineResults.resultsWithAbsolutePaths + } + + /// 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(BaselineResults.self, from: data).groupedByFile() + + } + + /// Creates a `Baseline` from a list of results. + /// + /// - parameter scanResults: The results for the baseline. + public init(scanResults: [ScanResult]) { + self.baseline = BaselineResults(scanResults).groupedByFile() + } + + /// Writes a `Baseline` to disk in JSON format. + /// + /// - parameter toPath: The path to write to. + public func write(toPath path: String) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted] + let data = try encoder.encode(sortedBaselineResults) + try data.write(to: URL(fileURLWithPath: path)) + } + + /// Filters out scan results that are present in the `Baseline`. + /// + /// - parameter scanResults: The scanResults to filter. + /// - Returns: The new scanResults. + public func filter(_ scanResults: [ScanResult]) -> [ScanResult] { + BaselineResults(scanResults).groupedByFile().flatMap { + filterFileResults($1.resultsWithAbsolutePaths) // TODO: should it be absolute paths + } + } + + private func filterFileResults(_ scanResults: [ScanResult]) -> [ScanResult] { + guard let firstResult = scanResults.first, + let baselineResults = baseline[firstResult.declaration.location.relativeLocation().file.path.string], + !baselineResults.isEmpty + else { + return scanResults + } + + let relativePathResults = BaselineResults(scanResults) + if relativePathResults == baselineResults { + return [] + } + + let resultsByKind = relativePathResults.groupedByKind( + filteredBy: baselineResults + ) + let baselineResultsByKind = baselineResults.groupedByKind( + filteredBy: relativePathResults + ) + + var filteredResults: Set = [] + + for (kind, results) in resultsByKind { + guard + let baselineResults = baselineResultsByKind[kind], + !baselineResults.isEmpty else { + filteredResults.formUnion(results) + continue + } + + let groupedResults = Dictionary(grouping: results, by: \.key) + let groupedBaselineResults = Dictionary(grouping: baselineResults, by: \.key) + + for (key, results) in groupedResults { + guard let baselineResults = groupedBaselineResults[key] else { + filteredResults.formUnion(results) + continue + } + if scanResults.count > baselineResults.count { + filteredResults.formUnion(results) + } + } + } + + let scanResultsWithAbsolutePaths = Set(filteredResults.resultsWithAbsolutePaths) + return scanResults.filter { scanResultsWithAbsolutePaths.contains($0) } + } +} + +// MARK: - Private + +private struct LineCache { + private var lines: [String: [String]] = [:] + + mutating func text(at location: SourceLocation) -> String { + let line = location.line - 1 + let file = location.file.path.string + if line > 0, 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 contents = try? String(contentsOfFile: file, encoding: .utf8) { + let fileLines = contents.components(separatedBy: CharacterSet.newlines) + return fileLines + } + return nil + } +} + +private extension Sequence where Element == BaselineResult { + init(_ scanResults: [ScanResult]) where Self == BaselineResults { + var lineCache = LineCache() + self = scanResults.map { + BaselineResult(scanResult: $0, text: lineCache.text(at: $0.declaration.location)) + } + } + + var resultsWithAbsolutePaths: [ScanResult] { + map { + $0.scanResult.withAbsoluteLocation() + } + } + + func groupedByFile() -> ResultsPerFile { + Dictionary(grouping: self, by: \.scanResult.declaration.location.file.path.string) + } + + func groupedByKind(filteredBy existingScanResults: BaselineResults = []) -> ResultsPerKind { + Dictionary(grouping: Set(self).subtracting(existingScanResults), by: \.scanResult.declaration.kind.rawValue) + } +} + +private var currentFilePath: FilePath = { .current }() + +private extension ScanResult { + func withRelativeLocation() -> ScanResult { + ScanResult( + declaration: declaration.withRelativeLocation(), + annotation: annotation.withRelativeLocation() + ) + } + + func withAbsoluteLocation() -> ScanResult { + ScanResult( + declaration: declaration.withAbsoluteLocation(), + annotation: annotation.withAbsoluteLocation() + ) + } +} + +private extension Declaration { + func withRelativeLocation() -> Declaration { + Declaration(kind: kind, usrs: usrsWithRelativeLocation(), location: location.relativeLocation()) + } + + func withAbsoluteLocation() -> Declaration { + Declaration(kind: kind, usrs: usrsWithAbsoluteLocation(), location: location.absoluteLocation()) + } + + private func usrsWithRelativeLocation() -> Set { + guard kind == .varParameter else { + return usrs + } + return Set(usrs.map { + let components = $0.split(separator: "-", maxSplits: 3) + guard components.count == 3 else { + return $0 + } + let path = components[2].replacingOccurrences(of: currentFilePath.string + "/", with: "") + return "\(components[0])-\(components[1])-\(path)" + }) + } + + private func usrsWithAbsoluteLocation() -> Set { + guard kind == .varParameter else { + return usrs + } + return Set(usrs.map { + let components = $0.split(separator: "-", maxSplits: 3) + guard components.count == 3 else { + return $0 + } + let absolutePath = currentFilePath.string + "/" + components[2] + return "\(components[0])-\(components[1])-\(absolutePath)" + }) + } +} + +private extension SourceLocation { + func relativeLocation() -> SourceLocation { + let relativePath = relativePath(to: file.path) + let file = SourceFile(path: relativePath, modules: file.modules) + return SourceLocation(file: file, line: line, column: column) + } + + func absoluteLocation() -> SourceLocation { + let absolutePath = FilePath(currentFilePath.string + "/" + file.path.string) + let file = SourceFile(path: absolutePath, modules: file.modules) + return SourceLocation(file: file, line: line, column: column) + } + + private func relativePath(to absolutePath: FilePath) -> FilePath { + // FilePath.relativePath is very slow + let absolutePathString = absolutePath.string + let currentPathString = currentFilePath.string + let relativePathString = absolutePathString.replacingOccurrences(of: currentPathString + "/", with: "") + let relativePath = FilePath(relativePathString) + return relativePath + } +} + +private extension ScanResult.Annotation { + func withRelativeLocation() -> ScanResult.Annotation { + switch self { + case .redundantProtocol(let references, let inherited): + return .redundantProtocol( + references: Set(references.map { $0.withRelativeLocation() }), + inherited: inherited + ) + default: + return self + } + } + + func withAbsoluteLocation() -> ScanResult.Annotation { + switch self { + case .redundantProtocol(let references, let inherited): + return .redundantProtocol( + references: Set(references.map { $0.withAbsoluteLocation() }), + inherited: inherited + ) + default: + return self + } + } +} + +private extension Reference { + func withRelativeLocation() -> Reference { + Reference(kind: kind, usr: usr, location: location.relativeLocation(), isRelated: isRelated) + } + + func withAbsoluteLocation() -> Reference { + Reference(kind: kind, usr: usr, location: location.absoluteLocation(), isRelated: isRelated) + } +} diff --git a/Sources/PeripheryKit/Indexer/Declaration.swift b/Sources/PeripheryKit/Indexer/Declaration.swift index 364a6e642..14d942460 100644 --- a/Sources/PeripheryKit/Indexer/Declaration.swift +++ b/Sources/PeripheryKit/Indexer/Declaration.swift @@ -1,7 +1,7 @@ import Foundation final class Declaration { - enum Kind: String, RawRepresentable, CaseIterable { + enum Kind: String, RawRepresentable, CaseIterable, Codable { case `associatedtype` = "associatedtype" case `class` = "class" case `enum` = "enum" @@ -227,7 +227,9 @@ final class Declaration { var isImplicit: Bool = false var isObjcAccessible: Bool = false - private let hashValueCache: Int + private lazy var hashValueCache: Int = { + usrs.hashValue + }() var ancestralDeclarations: Set { var maybeParent = parent @@ -283,7 +285,6 @@ final class Declaration { self.kind = kind self.usrs = usrs self.location = location - self.hashValueCache = usrs.hashValue } func isDeclaredInExtension(kind: Declaration.Kind) -> Bool { @@ -292,6 +293,14 @@ final class Declaration { } } +extension Declaration: Codable { + enum CodingKeys: CodingKey { + case location + case kind + case usrs + } +} + extension Declaration: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(hashValueCache) diff --git a/Sources/PeripheryKit/Indexer/Reference.swift b/Sources/PeripheryKit/Indexer/Reference.swift index 01127aff1..6be408361 100644 --- a/Sources/PeripheryKit/Indexer/Reference.swift +++ b/Sources/PeripheryKit/Indexer/Reference.swift @@ -32,14 +32,15 @@ final class Reference { let usr: String var role: Role = .unknown - private let hashValueCache: Int + private lazy var hashValueCache: Int = { + [usr.hashValue, location.hashValue, isRelated.hashValue].hashValue + }() init(kind: Declaration.Kind, usr: String, location: SourceLocation, isRelated: Bool = false) { self.kind = kind self.usr = usr self.isRelated = isRelated self.location = location - self.hashValueCache = [usr.hashValue, location.hashValue, isRelated.hashValue].hashValue } var descendentReferences: Set { @@ -47,6 +48,15 @@ final class Reference { } } +extension Reference: Codable { + enum CodingKeys: CodingKey { + case location + case kind + case isRelated + case usr + } +} + extension Reference: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(hashValueCache) diff --git a/Sources/PeripheryKit/Indexer/SourceFile.swift b/Sources/PeripheryKit/Indexer/SourceFile.swift index dea3db26d..25951c608 100644 --- a/Sources/PeripheryKit/Indexer/SourceFile.swift +++ b/Sources/PeripheryKit/Indexer/SourceFile.swift @@ -1,7 +1,7 @@ import Foundation import SystemPackage -class SourceFile { +class SourceFile: Codable { let path: FilePath let modules: Set var importStatements: [ImportStatement] = [] @@ -10,6 +10,11 @@ class SourceFile { self.path = path self.modules = modules } + + enum CodingKeys: CodingKey { + case path + case modules + } } extension SourceFile: Hashable { diff --git a/Sources/PeripheryKit/Indexer/SourceLocation.swift b/Sources/PeripheryKit/Indexer/SourceLocation.swift index 6af1a8eae..4d7d7b6c8 100644 --- a/Sources/PeripheryKit/Indexer/SourceLocation.swift +++ b/Sources/PeripheryKit/Indexer/SourceLocation.swift @@ -1,17 +1,18 @@ import Foundation -class SourceLocation { +class SourceLocation: Codable { let file: SourceFile let line: Int let column: Int - private let hashValueCache: Int + private lazy var hashValueCache: Int = { + [file.hashValue, line, column].hashValue + }() init(file: SourceFile, line: Int, column: Int) { self.file = file self.line = line self.column = column - self.hashValueCache = [file.hashValue, line, column].hashValue } // MARK: - Private @@ -37,7 +38,6 @@ extension SourceLocation: Equatable { extension SourceLocation: Hashable { func hash(into hasher: inout Hasher) { - hasher.combine(hashValueCache) } } diff --git a/Sources/PeripheryKit/ScanResult.swift b/Sources/PeripheryKit/ScanResult.swift index 00dfca5d8..7c4d27945 100644 --- a/Sources/PeripheryKit/ScanResult.swift +++ b/Sources/PeripheryKit/ScanResult.swift @@ -1,11 +1,59 @@ import Foundation -public struct ScanResult { - enum Annotation { +public struct ScanResult: Codable, Hashable { + enum Annotation: Codable, Hashable { case unused case assignOnlyProperty case redundantProtocol(references: Set, inherited: Set) case redundantPublicAccessibility(modules: Set) + + enum CodingKeys: CodingKey { + case unused + case assignOnlyProperty + case redundantProtocol + case redundantPublicAccessibility + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let key = container.allKeys.first + + switch key { + case .unused: + self = .unused + case .assignOnlyProperty: + self = .assignOnlyProperty + case .redundantProtocol: + var nestedContainer = try container.nestedUnkeyedContainer(forKey: .redundantProtocol) + let references = Set(try nestedContainer.decode([Reference].self)) + let inherited = Set(try nestedContainer.decode([String].self)) + self = .redundantProtocol(references: references, inherited: inherited) + case .redundantPublicAccessibility: + let modules = Set(try container.decode([String].self, forKey: .redundantPublicAccessibility)) + self = .redundantPublicAccessibility(modules: modules) + default: + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: container.codingPath, debugDescription: "Unabled to decode enum.") + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .unused: + try container.encode(true, forKey: .unused) + case .assignOnlyProperty: + try container.encode(true, forKey: .assignOnlyProperty) + case .redundantProtocol(let references, let inherited): + var nestedContainer = container.nestedUnkeyedContainer(forKey: .redundantProtocol) + try nestedContainer.encode(Array(references).sorted()) + try nestedContainer.encode(Array(inherited).sorted()) + case .redundantPublicAccessibility(let modules): + try container.encode(Array(modules).sorted(), forKey: .redundantPublicAccessibility) + } + } } let declaration: Declaration diff --git a/Sources/Shared/Configuration.swift b/Sources/Shared/Configuration.swift index 9204b2b91..46b492270 100644 --- a/Sources/Shared/Configuration.swift +++ b/Sources/Shared/Configuration.swift @@ -122,6 +122,12 @@ public final class Configuration { @Setting(key: "json_package_manifest_path", defaultValue: nil) public var jsonPackageManifestPath: String? + @Setting(key: "baseline", defaultValue: nil) + public var baseline: String? + + @Setting(key: "writeBaseline", defaultValue: nil) + public var writeBaseline: String? + // Non user facing. public var guidedSetup: Bool = false public var removalOutputBasePath: FilePath?