Created
August 18, 2021 18:28
-
-
Save krzysztofzablocki/0dbe6461861d3fab173d48337757fa49 to your computer and use it in GitHub Desktop.
Higher order reducer for TCA that enables better debugging
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
import ComposableArchitecture | |
import Difference | |
import Foundation | |
/// A container for storing action filters. | |
/// | |
/// The logic behind having this rather than a normal closure is that it allows us to namespace and gather action filters together in a consistent manner. | |
/// - Note: You should be adding extensions in your modules and exposing common filters you might want to use to focus your debugging work, e.g. | |
/// ```swift | |
/// extension ActionFilter where Action == AppAction { | |
/// static var windowActions: Self { | |
/// Self(isIncluded: { | |
/// switch $0 { | |
/// case .windows: | |
/// return true | |
/// default: | |
/// return false | |
/// } | |
/// }) | |
/// } | |
/// } | |
/// ``` | |
public struct ActionFilter<Action: Equatable> { | |
let isIncluded: (Action) -> Bool | |
public init(isIncluded: @escaping (Action) -> Bool) { | |
self.isIncluded = isIncluded | |
} | |
func callAsFunction(_ action: Action) -> Bool { | |
isIncluded(action) | |
} | |
/// Include all actions | |
public static var all: Self { | |
.init(isIncluded: { _ in true }) | |
} | |
/// negates the filter | |
public static func not(_ filter: Self) -> Self { | |
.init(isIncluded: { !filter($0) }) | |
} | |
/// Allows all actions except those specified | |
public static func allExcept(_ actions: Self...) -> Self { | |
allExcept(actions) | |
} | |
/// Allows all actions except those specified | |
public static func allExcept(_ actions: [Self]) -> Self { | |
.init(isIncluded: { action in | |
!actions.contains(where: { $0(action) }) | |
}) | |
} | |
/// Allows any of the specified actions | |
public static func anyOf(_ actions: Self...) -> Self { | |
.anyOf(actions) | |
} | |
/// Allows any of the specified actions | |
public static func anyOf(_ actions: [Self]) -> Self { | |
.init(isIncluded: { action in | |
actions.contains(where: { $0(action) }) | |
}) | |
} | |
} | |
extension Reducer where State: Equatable, Action: Equatable { | |
/// Adds diffing instrumentation to the TCA | |
/// - Parameters: | |
/// - actionFormat: whether to use prettyPrint or just name labels | |
/// - allowedActions: Lets you specify actions that you care about. Defaults to allowing all actions. | |
/// - toDebugEnvironment: Lets you construct custom environment for printing | |
/// - Note: Don't use this at root reducer level because it's going to be slow due to all reflection / equality checks | |
/// - Returns: Wrapped reducer | |
public func debugDiffing( | |
actionFormat: ActionFormat = .prettyPrint, | |
allowedActions: ActionFilter<Action> = .all, | |
environment toDebugEnvironment: @escaping (Environment) -> DebugDiffingEnvironment = { _ in | |
DebugDiffingEnvironment() | |
} | |
) -> Self { | |
.init { state, action, env -> Effect<Action, Never> in | |
let oldState = state | |
let effects = self.run(&state, action, env) | |
let newState = state | |
guard allowedActions(action) else { | |
return effects | |
} | |
let debugEnvironment = toDebugEnvironment(env) | |
return .merge( | |
.fireAndForget { | |
debugEnvironment.queue.async { | |
let actionOutput = | |
actionFormat == .prettyPrint | |
? debugOutput(action).indent(by: 2) | |
: debugCaseOutput(action).indent(by: 2) | |
var stateOutput: String = "🖨️ (No state changes)" | |
if oldState != newState { | |
stateOutput = diff(oldState, newState, indentationType: .pipe, skipPrintingOnDiffCount: true, nameLabels: .comparing).joined(separator: ", ") | |
} | |
debugEnvironment.printer( | |
""" | |
🎬 Received action: | |
\(actionOutput) | |
🖨️ State: | |
\(stateOutput) | |
""" | |
) | |
} | |
}, | |
effects | |
) | |
} | |
} | |
// This is internal code from TCA | |
// swiftlint:disable all | |
func debugCaseOutput(_ value: Any) -> String { | |
func debugCaseOutputHelp(_ value: Any) -> String { | |
let mirror = Mirror(reflecting: value) | |
switch mirror.displayStyle { | |
case .enum: | |
guard let child = mirror.children.first else { | |
let childOutput = "\(value)" | |
return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)" | |
} | |
let childOutput = debugCaseOutputHelp(child.value) | |
return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")" | |
case .tuple: | |
return mirror.children.map { label, value in | |
let childOutput = debugCaseOutputHelp(value) | |
return | |
"\(label.map { isUnlabeledArgument($0) ? "_:" : "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")" | |
} | |
.joined(separator: ", ") | |
default: | |
return "" | |
} | |
} | |
return "\(type(of: value))\(debugCaseOutputHelp(value))" | |
} | |
private func isUnlabeledArgument(_ label: String) -> Bool { | |
label.contains(where: { $0 != "." && !$0.isNumber }) | |
} | |
func debugOutput(_ value: Any, indent: Int = 0) -> String { | |
var visitedItems: Set<ObjectIdentifier> = [] | |
func debugOutputHelp(_ value: Any, indent: Int = 0) -> String { | |
let mirror = Mirror(reflecting: value) | |
switch (value, mirror.displayStyle) { | |
case let (value as CustomDebugOutputConvertible, _): | |
return value.debugOutput.indent(by: indent) | |
case (_, .collection?): | |
return """ | |
[ | |
\(mirror.children.map { "\(debugOutput($0.value, indent: 2)),\n" }.joined())] | |
""" | |
.indent(by: indent) | |
case (_, .dictionary?): | |
let pairs = mirror.children.map { _, value -> String in | |
let pair = value as! (key: AnyHashable, value: Any) | |
return | |
"\("\(debugOutputHelp(pair.key.base)): \(debugOutputHelp(pair.value)),".indent(by: 2))\n" | |
} | |
return """ | |
[ | |
\(pairs.sorted().joined())] | |
""" | |
.indent(by: indent) | |
case (_, .set?): | |
return """ | |
Set([ | |
\(mirror.children.map { "\(debugOutputHelp($0.value, indent: 2)),\n" }.sorted().joined())]) | |
""" | |
.indent(by: indent) | |
case (_, .optional?): | |
return mirror.children.isEmpty | |
? "nil".indent(by: indent) | |
: debugOutputHelp(mirror.children.first!.value, indent: indent) | |
case (_, .enum?) where !mirror.children.isEmpty: | |
let child = mirror.children.first! | |
let childMirror = Mirror(reflecting: child.value) | |
let elements = | |
childMirror.displayStyle != .tuple | |
? debugOutputHelp(child.value, indent: 2) | |
: childMirror.children.map { child -> String in | |
let label = child.label! | |
return "\(label.hasPrefix(".") ? "" : "\(label): ")\(debugOutputHelp(child.value))" | |
} | |
.joined(separator: ",\n") | |
.indent(by: 2) | |
return """ | |
\(mirror.subjectType).\(child.label!)( | |
\(elements) | |
) | |
""" | |
.indent(by: indent) | |
case (_, .enum?): | |
return """ | |
\(mirror.subjectType).\(value) | |
""" | |
.indent(by: indent) | |
case (_, .struct?) where !mirror.children.isEmpty: | |
let elements = mirror.children | |
.map { "\($0.label.map { "\($0): " } ?? "")\(debugOutputHelp($0.value))".indent(by: 2) } | |
.joined(separator: ",\n") | |
return """ | |
\(mirror.subjectType)( | |
\(elements) | |
) | |
""" | |
.indent(by: indent) | |
case let (value as AnyObject, .class?) | |
where !mirror.children.isEmpty && !visitedItems.contains(ObjectIdentifier(value)): | |
visitedItems.insert(ObjectIdentifier(value)) | |
let elements = mirror.children | |
.map { "\($0.label.map { "\($0): " } ?? "")\(debugOutputHelp($0.value))".indent(by: 2) } | |
.joined(separator: ",\n") | |
return """ | |
\(mirror.subjectType)( | |
\(elements) | |
) | |
""" | |
.indent(by: indent) | |
case let (value as AnyObject, .class?) | |
where !mirror.children.isEmpty && visitedItems.contains(ObjectIdentifier(value)): | |
return "\(mirror.subjectType)(↩︎)" | |
case let (value as CustomStringConvertible, .class?): | |
return value.description | |
.replacingOccurrences( | |
of: #"^<([^:]+): 0x[^>]+>$"#, with: "$1()", options: .regularExpression | |
) | |
.indent(by: indent) | |
case let (value as CustomDebugStringConvertible, _): | |
return value.debugDescription | |
.replacingOccurrences( | |
of: #"^<([^:]+): 0x[^>]+>$"#, with: "$1()", options: .regularExpression | |
) | |
.indent(by: indent) | |
case let (value as CustomStringConvertible, _): | |
return value.description | |
.indent(by: indent) | |
case (_, .struct?), (_, .class?): | |
return "\(mirror.subjectType)()" | |
.indent(by: indent) | |
case (_, .tuple?) where mirror.children.isEmpty: | |
return "()" | |
.indent(by: indent) | |
case (_, .tuple?): | |
let elements = mirror.children.map { child -> String in | |
let label = child.label! | |
return "\(label.hasPrefix(".") ? "" : "\(label): ")\(debugOutputHelp(child.value))" | |
.indent(by: 2) | |
} | |
return """ | |
( | |
\(elements.joined(separator: ",\n")) | |
) | |
""" | |
.indent(by: indent) | |
case (_, nil): | |
return "\(value)" | |
.indent(by: indent) | |
@unknown default: | |
return "\(value)" | |
.indent(by: indent) | |
} | |
} | |
return debugOutputHelp(value, indent: indent) | |
} | |
} | |
extension String { | |
func indent(by indent: Int) -> String { | |
let indentation = String(repeating: " ", count: indent) | |
return indentation + replacingOccurrences(of: "\n", with: "\n\(indentation)") | |
} | |
} | |
public struct DebugDiffingEnvironment { | |
public var printer: (String) -> Void | |
public var queue: DispatchQueue | |
public init( | |
printer: @escaping (String) -> Void = { print($0) }, | |
queue: DispatchQueue | |
) { | |
self.printer = printer | |
self.queue = queue | |
} | |
public init( | |
printer: @escaping (String) -> Void = { print($0) } | |
) { | |
self.init(printer: printer, queue: _queue) | |
} | |
} | |
private let _queue = DispatchQueue( | |
label: "com.merowing.info.DebugDiffingEnvironment", | |
qos: .background | |
) |
Did you try to present this in pointfree repo? Maybe this idea could be incorporated into the project.
CustomDebugOutputConvertible was removed
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I think Action's Equatable requirement is not needed(since you need to provide
isIncluded
anyway)