Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a baseline capability to Periphery #733

Closed
20 changes: 19 additions & 1 deletion Sources/Frontend/Commands/ScanBehavior.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions Sources/Frontend/Commands/ScanCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
289 changes: 289 additions & 0 deletions Sources/PeripheryKit/Baseline.swift
Original file line number Diff line number Diff line change
@@ -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<BaselineResult> = []

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<String> {
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<String> {
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)
}
}
15 changes: 12 additions & 3 deletions Sources/PeripheryKit/Indexer/Declaration.swift
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<Declaration> {
var maybeParent = parent
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
Loading