Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Higher order reducer for TCA that enables better debugging
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
)
@AndreiVidrasco

This comment has been minimized.

Copy link

@AndreiVidrasco AndreiVidrasco commented Aug 18, 2021

I think Action's Equatable requirement is not needed(since you need to provide isIncluded anyway)

@tomassliz

This comment has been minimized.

Copy link

@tomassliz tomassliz commented Sep 19, 2021

Did you try to present this in pointfree repo? Maybe this idea could be incorporated into the project.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment