Files
SwiftLint/Source/SwiftLintFramework/Configuration/Configuration+FileGraph.swift
T
Danny Mösch a6c4fd98bc Move files from SwiftLintCore to SwiftLintFramework
Ideally, SwiftLintCore would some day only contain components
that are needed to define rules. Consequently, it would be the
only bundle required to import for (external) rule development.
2024-12-23 12:51:43 +01:00

293 lines
13 KiB
Swift

import Foundation
package extension Configuration {
struct FileGraph: Hashable {
// MARK: - Properties
private static let defaultRemoteConfigTimeout: TimeInterval = 2
private static let defaultRemoteConfigTimeoutIfCached: TimeInterval = 1
internal let rootDirectory: String
private let ignoreParentAndChildConfigs: Bool
private var vertices: Set<Vertex>
private var edges: Set<Edge>
private var isBuilt = false
// MARK: - Initializers
internal init(commandLineChildConfigs: [String], rootDirectory: String, ignoreParentAndChildConfigs: Bool) {
let verticesArray = commandLineChildConfigs.map { config in
Vertex(string: config, rootDirectory: rootDirectory, isInitialVertex: true)
}
vertices = Set(verticesArray)
edges = Set(zip(verticesArray, verticesArray.dropFirst()).map { Edge(parent: $0.0, child: $0.1) })
self.rootDirectory = rootDirectory
self.ignoreParentAndChildConfigs = ignoreParentAndChildConfigs
}
/// Dummy init to get a FileGraph that just represents a root directory
internal init(rootDirectory: String) {
self.init(
commandLineChildConfigs: [],
rootDirectory: rootDirectory,
ignoreParentAndChildConfigs: false
)
isBuilt = true
}
// MARK: - Methods
internal mutating func resultingConfiguration(
enableAllRules: Bool,
onlyRule: [String],
cachePath: String?
) throws -> Configuration {
// Build if needed
if !isBuilt {
try build()
}
return try merged(
configurationData: try validate(),
enableAllRules: enableAllRules,
onlyRule: onlyRule,
cachePath: cachePath
)
}
internal func includesFile(atPath path: String) -> Bool {
guard isBuilt else { return false }
return vertices.contains { vertex in
if case let .existing(filePath) = vertex.filePath {
return path == filePath
}
return false
}
}
// MARK: Building
private mutating func build() throws {
for vertex in vertices {
try process(vertex: vertex)
}
isBuilt = true
}
private mutating func process(
vertex: Vertex,
remoteConfigTimeoutOverride: TimeInterval? = nil,
remoteConfigTimeoutIfCachedOverride: TimeInterval? = nil
) throws {
try vertex.build(
remoteConfigTimeout: remoteConfigTimeoutOverride ?? Configuration.FileGraph.defaultRemoteConfigTimeout,
remoteConfigTimeoutIfCached: remoteConfigTimeoutIfCachedOverride
?? remoteConfigTimeoutOverride ?? Configuration.FileGraph.defaultRemoteConfigTimeoutIfCached
)
if !ignoreParentAndChildConfigs {
try processPossibleReferenceIgnoringFileAbsence(
ofType: .childConfig,
from: vertex,
remoteConfigTimeoutOverride: remoteConfigTimeoutOverride,
remoteConfigTimeoutIfCachedOverride: remoteConfigTimeoutIfCachedOverride)
try processPossibleReferenceIgnoringFileAbsence(
ofType: .parentConfig,
from: vertex,
remoteConfigTimeoutOverride: remoteConfigTimeoutOverride,
remoteConfigTimeoutIfCachedOverride: remoteConfigTimeoutIfCachedOverride)
}
}
private mutating func processPossibleReferenceIgnoringFileAbsence(
ofType type: EdgeType,
from vertex: Vertex,
remoteConfigTimeoutOverride: TimeInterval?,
remoteConfigTimeoutIfCachedOverride: TimeInterval?
) throws {
do {
try processPossibleReference(
ofType: type,
from: vertex,
remoteConfigTimeoutOverride: remoteConfigTimeoutOverride,
remoteConfigTimeoutIfCachedOverride: remoteConfigTimeoutIfCachedOverride
)
} catch {
// If a child or parent config file doesn't exist, do not fail the rest of the config tree.
// Instead, just ignore this leaf of the config. Otherwise, rethrow the error.
guard case let Issue.fileNotFound(path) = error else {
throw error
}
queuedPrintError("""
A local configuration at \(path) was not found. \
Ignoring this part of the configuration.
"""
)
}
}
private mutating func processPossibleReference(
ofType type: EdgeType,
from vertex: Vertex,
remoteConfigTimeoutOverride: TimeInterval?,
remoteConfigTimeoutIfCachedOverride: TimeInterval?
) throws {
let key = type == .childConfig ? Configuration.Key.childConfig.rawValue
: Configuration.Key.parentConfig.rawValue
if let reference = vertex.configurationDict[key] as? String {
let referencedVertex = Vertex(string: reference, rootDirectory: vertex.rootDirectory,
isInitialVertex: false)
// Local vertices are allowed to have local / remote references
// Remote vertices are only allowed to have remote references
if vertex.originatesFromRemote && !referencedVertex.originatesFromRemote {
throw Issue.genericWarning("Remote configs are not allowed to reference local configs.")
}
let existingVertex = findPossiblyExistingVertex(sameAs: referencedVertex)
let existingVertexCopy = existingVertex.map { $0.copy(withNewRootDirectory: rootDirectory) }
edges.insert(
type == .childConfig
? Edge(parent: vertex, child: existingVertexCopy ?? referencedVertex)
: Edge(parent: existingVertexCopy ?? referencedVertex, child: vertex)
)
if existingVertex == nil {
vertices.insert(referencedVertex)
// Use timeout config from vertex / parent of vertex if some
let remoteConfigTimeout =
vertex.configurationDict[Configuration.Key.remoteConfigTimeout.rawValue]
as? TimeInterval
?? remoteConfigTimeoutOverride // from vertex parent
let remoteConfigTimeoutIfCached =
vertex.configurationDict[Configuration.Key.remoteConfigTimeoutIfCached.rawValue]
as? TimeInterval
?? remoteConfigTimeoutIfCachedOverride // from vertex parent
try process(
vertex: referencedVertex,
remoteConfigTimeoutOverride: remoteConfigTimeout,
remoteConfigTimeoutIfCachedOverride: remoteConfigTimeoutIfCached
)
}
}
}
private func findPossiblyExistingVertex(sameAs vertex: Vertex) -> Vertex? {
vertices.first {
$0.originalRemoteString != nil && $0.originalRemoteString == vertex.originalRemoteString
} ?? vertices.first { $0.filePath == vertex.filePath }
}
// MARK: Validating
/// Validates the Graph and throws failures
/// If successful, returns array of configuration dicts that represents the graph
private func validate() throws -> [(configurationDict: [String: Any], rootDirectory: String)] {
// Detect cycles via back-edge detection during DFS
func walkDown(stack: [Vertex]) throws {
// Please note that the equality check (`==`), not the identity check (`===`) is used
let children = edges.filter { $0.parent == stack.last }.map { $0.child! }
if stack.contains(where: children.contains) {
throw Issue.genericWarning("There's a cycle of child / parent config references. "
+ "Please check the hierarchy of configuration files passed via the command line "
+ "and the childConfig / parentConfig entries within them.")
}
try children.forEach { try walkDown(stack: stack + [$0]) }
}
try vertices.forEach { try walkDown(stack: [$0]) }
// Detect ambiguities
if (edges.contains { edge in edges.filter { $0.parent == edge.parent }.count > 1 }) {
throw Issue.genericWarning("There's an ambiguity in the child / parent configuration tree: "
+ "More than one parent is declared for a specific configuration, "
+ "where there should only be exactly one.")
}
if (edges.contains { edge in edges.filter { $0.child == edge.child }.count > 1 }) {
throw Issue.genericWarning("There's an ambiguity in the child / parent configuration tree: "
+ "More than one child is declared for a specific configuration, "
+ "where there should only be exactly one.")
}
// The graph should be like an array if validation passed -> return that array
guard
let startingVertex = (vertices.first { vertex in !edges.contains { $0.child == vertex } })
else {
guard vertices.isEmpty else {
throw Issue.genericWarning("Unknown Configuration Error")
}
return []
}
var verticesToMerge = [startingVertex]
while let vertex = (edges.first { $0.parent == verticesToMerge.last }?.child) {
guard !verticesToMerge.contains(vertex) else {
// This shouldn't happen on a cycle free graph but let's safeguard
throw Issue.genericWarning("Unknown Configuration Error")
}
verticesToMerge.append(vertex)
}
return verticesToMerge.map {
(
configurationDict: $0.configurationDict,
rootDirectory: $0.rootDirectory
)
}
}
// MARK: Merging
private func merged(
configurationData: [(configurationDict: [String: Any], rootDirectory: String)],
enableAllRules: Bool,
onlyRule: [String],
cachePath: String?
) throws -> Configuration {
// Split into first & remainder; use empty dict for first if the array is empty
let firstConfigurationData = configurationData.first ?? (configurationDict: [:], rootDirectory: "")
let configurationData = Array(configurationData.dropFirst())
// Build first configuration
var firstConfiguration = try Configuration(
dict: firstConfigurationData.configurationDict,
enableAllRules: enableAllRules,
onlyRule: onlyRule,
cachePath: cachePath
)
// Set the config's rootDirectory to rootDirectory (+ adjust included / excluded paths that relate to it).
// firstConfigurationData.rootDirectory may be different from rootDirectory,
// e. g. when ../file.yml is passed as the first config
firstConfiguration.fileGraph = Self(rootDirectory: rootDirectory)
firstConfiguration.makeIncludedAndExcludedPaths(
relativeTo: rootDirectory,
previousBasePath: firstConfigurationData.rootDirectory
)
// Build succeeding configurations
return try configurationData.reduce(firstConfiguration) {
var childConfiguration = try Configuration(
parentConfiguration: $0,
dict: $1.configurationDict,
enableAllRules: enableAllRules,
onlyRule: onlyRule,
cachePath: cachePath
)
childConfiguration.fileGraph = Self(rootDirectory: $1.rootDirectory)
return $0.merged(withChild: childConfiguration, rootDirectory: rootDirectory)
}
}
}
}