Skip to content

Instantly share code, notes, and snippets.

@krzysztofzablocki
Created August 18, 2021 18:28
Show Gist options
  • Star 14 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save krzysztofzablocki/0dbe6461861d3fab173d48337757fa49 to your computer and use it in GitHub Desktop.
Save krzysztofzablocki/0dbe6461861d3fab173d48337757fa49 to your computer and use it in GitHub Desktop.
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
Copy link

AndreiVidrasco commented Aug 18, 2021

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

@tomassliz
Copy link

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

@shawnkoh
Copy link

CustomDebugOutputConvertible was removed

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