diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..021b91a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +max_line_length = 120 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{js,ts,css,html}] +indent_size = 2 + +[*.{md,mdx,diff}] +indent_size = 2 +trim_trailing_whitespace = false + +[Makefile] +tab_width = 4 +indent_style = tab + +[COMMIT_EDITMSG] +max_line_length = 0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2335eba --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +*.jar binary +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b81eb3e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: Canopy CI + +on: + push: + branches: [main, master] + paths-ignore: + - '**.md' + - '**.txt' + - 'docs/**' + - 'Examples/**' + - 'README*' + - 'CONTRIBUTING*' + - 'TESTING*' + pull_request: + branches: [main, master] + paths-ignore: + - '**.md' + - '**.txt' + - 'docs/**' + - 'Examples/**' + - 'README*' + - 'CONTRIBUTING*' + - 'TESTING*' + +jobs: + lint: + name: SwiftLint + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Setup SwiftLint + run: brew install swiftlint + + - name: Run SwiftLint + run: swiftlint + + build-and-test: + name: Build & Test + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Build for iOS Simulator + run: | + xcodebuild -project Canopy.xcodeproj \ + -scheme Canopy \ + -destination "generic/platform=iOS Simulator" \ + -configuration Debug \ + build \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO + + - name: Run SPM Tests + run: swift test + diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..0929b27 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,67 @@ +disabled_rules: + - trailing_whitespace + - todo + - multiple_closures_with_trailing_closure + - optional_data_string_conversion + - function_parameter_count + +opt_in_rules: + - empty_count + - empty_string + - force_unwrapping + - explicit_init + - first_where + - overridden_super_call + - redundant_nil_coalescing + - vertical_whitespace_closing_braces + - weak_delegate + +line_length: + warning: 120 + error: 150 + ignores_function_declarations: true + ignores_comments: true + +file_length: + warning: 500 + error: 1000 + +function_body_length: + warning: 60 + error: 100 + +type_body_length: + warning: 300 + error: 500 + +identifier_name: + min_length: + warning: 3 + error: 2 + max_length: + warning: 40 + error: 50 + +type_name: + min_length: + warning: 3 + error: 2 + max_length: + warning: 40 + error: 50 + +large_tuple: + warning: 3 + error: 4 + +excluded: + - Carthage + - Pods + - Canopy.xcodeproj + - .build + +included: + - Canopy + - Sources + - Examples + - Tests diff --git a/Canopy.xcodeproj/xcshareddata/xcschemes/Canopy.xcscheme b/Canopy.xcodeproj/xcshareddata/xcschemes/Canopy.xcscheme new file mode 100644 index 0000000..158159f --- /dev/null +++ b/Canopy.xcodeproj/xcshareddata/xcschemes/Canopy.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Canopy/AppDelegate.swift b/Canopy/AppDelegate.swift index 89ffb89..e35357e 100644 --- a/Canopy/AppDelegate.swift +++ b/Canopy/AppDelegate.swift @@ -21,8 +21,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Canopy.plant(DebugTree()) #endif - crashBufferTree = CrashBufferTree(maxSize: 50) - Canopy.plant(crashBufferTree!) + let crashTree = CrashBufferTree(maxSize: 50) + crashBufferTree = crashTree + Canopy.plant(crashTree) Canopy.v("Canopy initialized with DebugTree and CrashBufferTree") } diff --git a/Canopy/SceneDelegate.swift b/Canopy/SceneDelegate.swift index e513bf9..2681215 100644 --- a/Canopy/SceneDelegate.swift +++ b/Canopy/SceneDelegate.swift @@ -11,12 +11,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard let _ = (scene as? UIWindowScene) else { return } + guard (scene as? UIWindowScene) != nil else { return } } func sceneDidDisconnect(_ scene: UIScene) { @@ -46,7 +45,4 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Use this method to save data, release shared resources, and store enough scene-specific state information // to restore the scene back to its current state. } - - } - diff --git a/Canopy/Sources/AsyncTree.swift b/Canopy/Sources/AsyncTree.swift index 6e2f21f..7bad8de 100644 --- a/Canopy/Sources/AsyncTree.swift +++ b/Canopy/Sources/AsyncTree.swift @@ -7,7 +7,9 @@ import Foundation -public final class AsyncTree: Tree { +/// A Tree wrapper that executes logs asynchronously on a background queue. +/// This prevents logging operations from blocking the calling thread. +public final class AsyncTree: Tree, @unchecked Sendable { private let wrapped: Tree private let queue: DispatchQueue @@ -32,19 +34,19 @@ public final class AsyncTree: Tree { line: UInt ) { let currentContext = CanopyContext.current - let capturedTag = self.explicitTag - self.explicitTag = nil // Clear immediately to prevent affecting subsequent logs + let capturedTag = explicitTag + explicitTag = nil let capturedMessage = formatMessage(message(), arguments) - queue.async { + queue.async { [wrapped] in let previous = CanopyContext.current CanopyContext.current = currentContext - self.wrapped.log( + wrapped.log( priority: priority, tag: capturedTag, message: capturedMessage, - arguments: arguments, + arguments: [], error: error, file: file, function: function, diff --git a/Canopy/Sources/Canopy.swift b/Canopy/Sources/Canopy.swift index 0195b52..69c5c28 100644 --- a/Canopy/Sources/Canopy.swift +++ b/Canopy/Sources/Canopy.swift @@ -8,118 +8,121 @@ import Foundation import os +// MARK: - Proxy for Tagged Logs (forward declaration needed) + +/// A proxy that adds a tag to all subsequent log calls. +public struct TaggedTreeProxy { + private let tag: String? + + init(tag: String?) { + self.tag = tag + } + + public func v(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { + Canopy.log(LogLevel.verbose, message(), args, file: file, function: function, line: line, withTag: tag) + } + + public func d(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { + Canopy.log(LogLevel.debug, message(), args, file: file, function: function, line: line, withTag: tag) + } + + public func i(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { + Canopy.log(LogLevel.info, message(), args, file: file, function: function, line: line, withTag: tag) + } + + public func w(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { + Canopy.log(LogLevel.warning, message(), args, file: file, function: function, line: line, withTag: tag) + } + + public func e(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { + Canopy.log(LogLevel.error, message(), args, file: file, function: function, line: line, withTag: tag) + } +} + +/// The main entry point for the Canopy logging framework. +/// All logging operations go through this enum. public enum Canopy { - private static var lock = os_unfair_lock() + private static let lock: NSLock = NSLock() private static var trees: [Tree] = [] - private static var cachedHasNonDebugTrees = false - private static var needsRecalc = true + private static var cachedHasNonDebugTrees: Bool = false + private static var needsRecalc: Bool = true public static func plant(_ trees: Tree...) { - os_unfair_lock_lock(&lock) - defer { os_unfair_lock_unlock(&lock) } + lock.lock() + defer { lock.unlock() } self.trees.append(contentsOf: trees) needsRecalc = true } public static func uprootAll() { - os_unfair_lock_lock(&lock) - defer { os_unfair_lock_unlock(&lock) } + lock.lock() + defer { lock.unlock() } trees.removeAll() needsRecalc = true } @discardableResult public static func tag(_ tag: String?) -> TaggedTreeProxy { - return TaggedTreeProxy(tag: tag) + TaggedTreeProxy(tag: tag) } // MARK: - Log Methods public static func v(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { - #if DEBUG log(LogLevel.verbose, message(), args, file: file, function: function, line: line) - #else - if hasNonDebugTrees() { - log(LogLevel.verbose, message(), args, file: file, function: function, line: line) - } - #endif } public static func d(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { - #if DEBUG log(LogLevel.debug, message(), args, file: file, function: function, line: line) - #else - if hasNonDebugTrees() { - log(LogLevel.debug, message(), args, file: file, function: function, line: line) - } - #endif } public static func i(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { - #if DEBUG log(LogLevel.info, message(), args, file: file, function: function, line: line) - #else - if hasNonDebugTrees() { - log(LogLevel.info, message(), args, file: file, function: function, line: line) - } - #endif } public static func w(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { - #if DEBUG log(LogLevel.warning, message(), args, file: file, function: function, line: line) - #else - if hasNonDebugTrees() { - log(LogLevel.warning, message(), args, file: file, function: function, line: line) - } - #endif } public static func e(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { - #if DEBUG log(LogLevel.error, message(), args, file: file, function: function, line: line) - #else - if hasNonDebugTrees() { - log(LogLevel.error, message(), args, file: file, function: function, line: line) - } - #endif } // MARK: - Internal Helpers private static func hasNonDebugTrees() -> Bool { if needsRecalc { - os_unfair_lock_lock(&lock) - cachedHasNonDebugTrees = trees.contains { !isDebugTree($0) } + lock.lock() + cachedHasNonDebugTrees = !trees.isEmpty && !trees.allSatisfy { $0 is DebugTree } needsRecalc = false - os_unfair_lock_unlock(&lock) + lock.unlock() } return cachedHasNonDebugTrees } - private static func isDebugTree(_ tree: Tree) -> Bool { - return tree is DebugTree - } - - private static func log( + fileprivate static func log( _ priority: LogLevel, - _ message: @autoclosure () -> String, + _ message: String, _ args: [CVarArg], file: StaticString, function: StaticString, line: UInt ) { - let capturedMessage = message() - os_unfair_lock_lock(&lock) - let treesToUse = self.trees - os_unfair_lock_unlock(&lock) + #if !DEBUG + guard hasNonDebugTrees() else { return } + #endif - treesToUse.forEach { tree in - guard tree.isLoggable(priority: priority) else { return } + let treesToUse: [Tree] + lock.lock() + treesToUse = trees + lock.unlock() + + for tree in treesToUse { + guard tree.isLoggable(priority: priority) else { continue } tree.log( priority: priority, tag: nil, - message: capturedMessage, + message: message, arguments: args, error: nil, file: file, @@ -128,60 +131,32 @@ public enum Canopy { ) } } -} - -// MARK: - Proxy for Tagged Logs -public struct TaggedTreeProxy { - private let tag: String? - init(tag: String?) { - self.tag = tag - } - - public func v(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { - Canopy.log(LogLevel.verbose, message(), args, file: file, function: function, line: line, withTag: tag) - } - - public func d(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { - Canopy.log(LogLevel.debug, message(), args, file: file, function: function, line: line, withTag: tag) - } - - public func i(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { - Canopy.log(LogLevel.info, message(), args, file: file, function: function, line: line, withTag: tag) - } - - public func w(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { - Canopy.log(LogLevel.warning, message(), args, file: file, function: function, line: line, withTag: tag) - } - - public func e(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { - Canopy.log(LogLevel.error, message(), args, file: file, function: function, line: line, withTag: tag) - } -} - -// MARK: - Helper for Tagged Logs -fileprivate extension Canopy { - static func log( + fileprivate static func log( _ priority: LogLevel, - _ message: @autoclosure () -> String, + _ message: String, _ args: [CVarArg], file: StaticString, function: StaticString, line: UInt, withTag tag: String? ) { - let capturedMessage = message() - os_unfair_lock_lock(&lock) - let treesToUse = self.trees - os_unfair_lock_unlock(&lock) + #if !DEBUG + guard hasNonDebugTrees() else { return } + #endif + + let treesToUse: [Tree] + lock.lock() + treesToUse = trees + lock.unlock() - treesToUse.forEach { tree in - guard tree.isLoggable(priority: priority) else { return } + for tree in treesToUse { + guard tree.isLoggable(priority: priority) else { continue } let taggedTree = tree.tag(tag) taggedTree.log( priority: priority, tag: nil, - message: capturedMessage, + message: message, arguments: args, error: nil, file: file, diff --git a/Canopy/Sources/CanopyContext.swift b/Canopy/Sources/CanopyContext.swift index 7379efc..02e0e54 100644 --- a/Canopy/Sources/CanopyContext.swift +++ b/Canopy/Sources/CanopyContext.swift @@ -12,7 +12,7 @@ import UIKit #endif enum CanopyContext { - nonisolated private static let threadKey = "CanopyContext.current" + nonisolated private static let threadKey: String = "CanopyContext.current" nonisolated static var current: String? { get { diff --git a/Canopy/Sources/CrashBufferTree.swift b/Canopy/Sources/CrashBufferTree.swift index 07bab58..124afb4 100644 --- a/Canopy/Sources/CrashBufferTree.swift +++ b/Canopy/Sources/CrashBufferTree.swift @@ -23,10 +23,17 @@ private func exitHandler() { crashBufferTreeInstance?.flush() } -public final class CrashBufferTree: Tree { +/// A Tree that buffers logs in memory and flushes them to a file on app exit or crash. +/// Uses locks for thread-safe access to internal state. +public final class CrashBufferTree: Tree, @unchecked Sendable { + /// Maximum number of logs to buffer. private let maxSize: Int + + /// Thread-unsafe buffer - protected by lock. nonisolated(unsafe) private var buffer: [String] = [] - nonisolated(unsafe) private var lock = os_unfair_lock() + + /// Lock for thread-safe buffer access. + private let lock = NSLock() public init(maxSize: Int = 100) { self.maxSize = maxSize @@ -65,18 +72,18 @@ public final class CrashBufferTree: Tree { checkAndFlushOnCrash() let effectiveTag = explicitTag ?? tag - explicitTag = nil // Clear to prevent affecting subsequent logs + explicitTag = nil let msg = "[\(priority)] \(effectiveTag ?? ""): \(message())" - os_unfair_lock_lock(&lock) + lock.lock() buffer.append(msg) if buffer.count > maxSize { buffer.removeFirst() } - os_unfair_lock_unlock(&lock) + lock.unlock() } nonisolated func flush() { - os_unfair_lock_lock(&lock) - defer { os_unfair_lock_unlock(&lock) } + lock.lock() + defer { lock.unlock() } guard let data = buffer.joined(separator: "\n").data(using: .utf8) else { return } guard let url = documentsURL()?.appendingPathComponent("canopy_crash_buffer.txt") else { return } try? data.write(to: url, options: .atomic) @@ -87,8 +94,8 @@ public final class CrashBufferTree: Tree { } nonisolated public func recentLogs() -> String { - os_unfair_lock_lock(&lock) - defer { os_unfair_lock_unlock(&lock) } + lock.lock() + defer { lock.unlock() } return buffer.joined(separator: "\n") } } diff --git a/Canopy/Sources/DebugTree.swift b/Canopy/Sources/DebugTree.swift index c432db2..d11637d 100644 --- a/Canopy/Sources/DebugTree.swift +++ b/Canopy/Sources/DebugTree.swift @@ -11,7 +11,9 @@ import Foundation import os.log #endif -open class DebugTree: Tree { +/// A Tree that logs messages to the system console. +/// Uses os.log on supported platforms, falls back to NSLog. +open class DebugTree: Tree, @unchecked Sendable { nonisolated public override func log( priority: LogLevel, @@ -24,8 +26,8 @@ open class DebugTree: Tree { line: UInt ) { let effectiveTag = explicitTag ?? tag ?? CanopyContext.current ?? autoTag(from: file) - explicitTag = nil // Clear to prevent affecting subsequent logs - let fullMessage = buildFullMessage(message(), arguments, error: error) + explicitTag = nil + let fullMessage = buildFullMessage(message(), error: error) let fileName = (file.withUTF8Buffer { String(decoding: $0, as: UTF8.self) } as NSString).lastPathComponent let sourceRef = "\(fileName):\(line)" @@ -43,12 +45,11 @@ open class DebugTree: Tree { NSLog("%@", output) } - nonisolated private func buildFullMessage(_ template: String, _ args: [CVarArg], error: Error?) -> String { - let msg = formatMessage(template, args) + nonisolated private func buildFullMessage(_ message: String, error: Error?) -> String { if let err = error { - return "\(msg) | Error: \(err.localizedDescription)" + return "\(message) | Error: \(err.localizedDescription)" } - return msg + return message } nonisolated private func autoTag(from file: StaticString) -> String { diff --git a/Canopy/Sources/Tree.swift b/Canopy/Sources/Tree.swift index f3a3a8a..8f1ccdb 100644 --- a/Canopy/Sources/Tree.swift +++ b/Canopy/Sources/Tree.swift @@ -7,8 +7,16 @@ import Foundation -open class Tree { +/// Base class for all logging trees. +/// Subclasses must ensure thread-safe access to properties using locks. +/// Marked as @unchecked Sendable because subclasses use locks for thread safety. +open class Tree: @unchecked Sendable { + /// Thread-unsafe tag property - must be protected by locks in subclasses. + /// Marked as nonisolated(unsafe) because subclasses use locks for thread safety. nonisolated(unsafe) var explicitTag: String? + + /// Thread-unsafe minimum log level - must be protected by locks in subclasses. + /// Marked as nonisolated(unsafe) because subclasses use locks for thread safety. nonisolated(unsafe) open var minLevel: LogLevel = .verbose @discardableResult @@ -18,7 +26,7 @@ open class Tree { } nonisolated open func isLoggable(priority: LogLevel) -> Bool { - return priority >= minLevel + priority >= minLevel } nonisolated open func log( @@ -45,9 +53,7 @@ open class Tree { guard !args.isEmpty else { return template } let specifierCount = template.components(separatedBy: "%").count - 1 - if specifierCount != args.count { - return template - } + guard specifierCount == args.count else { return template } return String(format: template, arguments: args) } diff --git a/Canopy/ViewController.swift b/Canopy/ViewController.swift index 2835659..6874e9a 100644 --- a/Canopy/ViewController.swift +++ b/Canopy/ViewController.swift @@ -66,7 +66,11 @@ class ViewController: UIViewController { stackView.addArrangedSubview(formatButton) // Tagged Log Button - let taggedButton = createButton(title: "Tagged Log", color: UIColor(red: 0.0, green: 0.75, blue: 0.75, alpha: 1.0), action: #selector(testTagged)) + let taggedButton = createButton( + title: "Tagged Log", + color: UIColor(red: 0.0, green: 0.75, blue: 0.75, alpha: 1.0), + action: #selector(testTagged) + ) stackView.addArrangedSubview(taggedButton) // Async Log Button @@ -74,7 +78,11 @@ class ViewController: UIViewController { stackView.addArrangedSubview(asyncButton) // View Crash Buffer Button - let viewBufferButton = createButton(title: "View Crash Buffer", color: .systemGray, action: #selector(viewCrashBuffer)) + let viewBufferButton = createButton( + title: "View Crash Buffer", + color: .systemGray, + action: #selector(viewCrashBuffer) + ) stackView.addArrangedSubview(viewBufferButton) view.addSubview(stackView) @@ -152,9 +160,9 @@ class ViewController: UIViewController { DispatchQueue.global(qos: .userInitiated).async { Canopy.v("Background task started") - for i in 1...5 { + for itemIndex in 1...5 { Thread.sleep(forTimeInterval: 0.1) - Canopy.d("Processing item %d of 5", i) + Canopy.d("Processing item %d of 5", itemIndex) } Canopy.i("Background task completed") @@ -182,5 +190,4 @@ class ViewController: UIViewController { alert.addAction(UIAlertAction(title: "OK", style: .default)) present(alert, animated: true) } - } diff --git a/CanopyTests/AsyncTreeTests.swift b/CanopyTests/AsyncTreeTests.swift new file mode 100644 index 0000000..52122eb --- /dev/null +++ b/CanopyTests/AsyncTreeTests.swift @@ -0,0 +1,164 @@ +// +// AsyncTreeTests.swift +// CanopyTests +// +// Tests for AsyncTree functionality +// + +import XCTest +@testable import Canopy + +final class AsyncTreeTests: XCTestCase { + + override func setUpWithError() throws { + Canopy.uprootAll() + } + + override func tearDownWithError() throws { + Canopy.uprootAll() + } + + // MARK: - Initialization Tests + + func testAsyncTreeWrapsTree() { + let innerTree = TestTree() + let asyncTree = AsyncTree(wrapping: innerTree) + + XCTAssertNotNil(asyncTree) + } + + func testAsyncTreeWithCustomQueue() { + let innerTree = TestTree() + let customQueue = DispatchQueue(label: "custom.queue") + let asyncTree = AsyncTree(wrapping: innerTree, on: customQueue) + + XCTAssertNotNil(asyncTree) + } + + // MARK: - Logging Tests + + func testAsyncTreeLogsAsynchronously() { + let innerTree = TestTree() + let asyncTree = AsyncTree(wrapping: innerTree) + Canopy.plant(asyncTree) + + let expectation = XCTestExpectation(description: "Async logging completes") + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(innerTree.logs.count, 1) + expectation.fulfill() + } + + Canopy.d("Test message") + + wait(for: [expectation], timeout: 1.0) + } + + func testAsyncTreeMultipleLogs() { + let innerTree = TestTree() + let asyncTree = AsyncTree(wrapping: innerTree) + Canopy.plant(asyncTree) + + let expectation = XCTestExpectation(description: "Multiple async logs complete") + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + XCTAssertEqual(innerTree.logs.count, 10) + expectation.fulfill() + } + + for i in 0..<10 { + Canopy.d("Message \(i)") + } + + wait(for: [expectation], timeout: 1.0) + } + + // MARK: - Min Level Tests + + func testAsyncTreeRespectsMinLevel() { + let innerTree = TestTree() + innerTree.minLevel = .error + + let asyncTree = AsyncTree(wrapping: innerTree) + asyncTree.minLevel = .warning + Canopy.plant(asyncTree) + + let expectation = XCTestExpectation(description: "Filtering test completes") + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(innerTree.logs.count, 0) + expectation.fulfill() + } + + Canopy.d("Debug message") + + wait(for: [expectation], timeout: 1.0) + } + + // MARK: - Tag Tests + + func testAsyncTreePreservesTag() { + let innerTree = TestTree() + let asyncTree = AsyncTree(wrapping: innerTree) + Canopy.plant(asyncTree) + + let expectation = XCTestExpectation(description: "Tag test completes") + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(innerTree.logs.count, 1) + XCTAssertEqual(innerTree.logs.first?.tag, "CustomTag") + expectation.fulfill() + } + + Canopy.tag("CustomTag").d("Test message") + + wait(for: [expectation], timeout: 1.0) + } + + // MARK: - Context Tests + + func testAsyncTreePreservesCanopyContext() { + let innerTree = TestTree() + let asyncTree = AsyncTree(wrapping: innerTree) + Canopy.plant(asyncTree) + + let expectation = XCTestExpectation(description: "Context test completes") + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(innerTree.logs.count, 1) + expectation.fulfill() + } + + CanopyContext.current = "TestContext" + Canopy.d("Message with context") + + wait(for: [expectation], timeout: 1.0) + } + + // MARK: - Thread Safety Tests + + func testAsyncTreeThreadSafety() { + let innerTree = TestTree() + let asyncTree = AsyncTree(wrapping: innerTree) + Canopy.plant(asyncTree) + + let expectation = XCTestExpectation(description: "Thread safety test") + expectation.expectedFulfillmentCount = 10 + + let queue = DispatchQueue.global(qos: .userInitiated) + for i in 0..<10 { + queue.async { + for j in 0..<100 { + Canopy.d("Thread \(i), Log \(j)") + } + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 5.0) + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + XCTAssertEqual(innerTree.logs.count, 1000) + } + } +} diff --git a/CanopyTests/CanopyTests.swift b/CanopyTests/CanopyTests.swift new file mode 100644 index 0000000..701b6de --- /dev/null +++ b/CanopyTests/CanopyTests.swift @@ -0,0 +1,329 @@ +import XCTest +@testable import Canopy + +final class CanopyTests: XCTestCase { + + override func setUpWithError() throws { + Canopy.uprootAll() + } + + override func tearDownWithError() throws { + Canopy.uprootAll() + } + + func testPlantAndUproot() throws { + let tree = TestTree() + Canopy.plant(tree) + + Canopy.d("Test") + + XCTAssertEqual(tree.logs.count, 1) + + Canopy.uprootAll() + + Canopy.d("After uproot") + + XCTAssertEqual(tree.logs.count, 1) + } + + func testUprootAll() throws { + let tree1 = TestTree() + let tree2 = TestTree() + Canopy.plant(tree1, tree2) + + Canopy.d("Test") + + XCTAssertEqual(tree1.logs.count, 1) + XCTAssertEqual(tree2.logs.count, 1) + + Canopy.uprootAll() + + Canopy.d("After uprootAll") + + XCTAssertEqual(tree1.logs.count, 1) + XCTAssertEqual(tree2.logs.count, 1) + } + + func testPlantMultipleTrees() throws { + let tree1 = TestTree() + let tree2 = TestTree() + let tree3 = TestTree() + + Canopy.plant(tree1, tree2, tree3) + + tree1.log(priority: .debug, tag: nil, message: "Test1", error: nil) + tree2.log(priority: .debug, tag: nil, message: "Test2", error: nil) + tree3.log(priority: .debug, tag: nil, message: "Test3", error: nil) + + XCTAssertEqual(tree1.logs.count, 1) + XCTAssertEqual(tree2.logs.count, 1) + XCTAssertEqual(tree3.logs.count, 1) + } + + func testCannotPlantItself() throws { + let tree = TestTree() + Canopy.plant(tree) + + tree.log(priority: .debug, tag: nil, message: "Test", error: nil) + + XCTAssertEqual(tree.logs.count, 1) + } + + func testVerboseLogging() throws { + let tree = TestTree() + tree.minLevel = .verbose + Canopy.plant(tree) + + Canopy.v("Verbose message") + XCTAssertEqual(tree.logs.count, 1) + XCTAssertEqual(tree.logs.first?.level, .verbose) + } + + func testDebugLogging() throws { + let tree = TestTree() + tree.minLevel = .debug + Canopy.plant(tree) + + Canopy.d("Debug message") + XCTAssertEqual(tree.logs.count, 1) + XCTAssertEqual(tree.logs.first?.level, .debug) + } + + func testInfoLogging() throws { + let tree = TestTree() + tree.minLevel = .info + Canopy.plant(tree) + + Canopy.i("Info message") + XCTAssertEqual(tree.logs.count, 1) + XCTAssertEqual(tree.logs.first?.level, .info) + } + + func testWarningLogging() throws { + let tree = TestTree() + tree.minLevel = .warning + Canopy.plant(tree) + + Canopy.w("Warning message") + XCTAssertEqual(tree.logs.count, 1) + XCTAssertEqual(tree.logs.first?.level, .warning) + } + + func testErrorLogging() throws { + let tree = TestTree() + tree.minLevel = .error + Canopy.plant(tree) + + Canopy.e("Error message") + XCTAssertEqual(tree.logs.count, 1) + XCTAssertEqual(tree.logs.first?.level, .error) + } + + func testMinLevelFiltering() throws { + let tree = TestTree() + tree.minLevel = .warning + Canopy.plant(tree) + + Canopy.v("Verbose") + Canopy.d("Debug") + Canopy.i("Info") + Canopy.w("Warning") + Canopy.e("Error") + + XCTAssertEqual(tree.logs.count, 2) + XCTAssertEqual(tree.logs[0].level, .warning) + XCTAssertEqual(tree.logs[1].level, .error) + } + + func testFormattedLogging() throws { + let tree = TestTree() + Canopy.plant(tree) + + Canopy.d("User %@ has %lld items", "Alice", 5) + + XCTAssertEqual(tree.logs.count, 1) + XCTAssertTrue(tree.logs.first?.message.contains("Alice") ?? false) + XCTAssertTrue(tree.logs.first?.message.contains("5") ?? false) + } + + func testTaggedLogging() throws { + let tree = TestTree() + Canopy.plant(tree) + + Canopy.tag("Network").d("Request started") + + XCTAssertEqual(tree.logs.count, 1) + XCTAssertEqual(tree.logs.first?.tag, "Network") + } + + func testTaggedLoggingChaining() throws { + let tree = TestTree() + Canopy.plant(tree) + + Canopy.tag("API").i("User %@ logged in", "Bob") + Canopy.tag("DB").w("Slow query detected") + + XCTAssertEqual(tree.logs.count, 2) + XCTAssertEqual(tree.logs[0].tag, "API") + XCTAssertEqual(tree.logs[1].tag, "DB") + } + + func testEmptyTag() throws { + let tree = TestTree() + Canopy.plant(tree) + + Canopy.tag("").d("Message with empty tag") + + XCTAssertEqual(tree.logs.count, 1) + XCTAssertNil(tree.logs.first?.tag) + } + + func testMultipleTreesReceiveLogs() throws { + let tree1 = TestTree() + let tree2 = TestTree() + + Canopy.plant(tree1, tree2) + + Canopy.i("Test message") + + XCTAssertEqual(tree1.logs.count, 1) + XCTAssertEqual(tree2.logs.count, 1) + } + + func testDifferentMinLevelsForTrees() throws { + let tree1 = TestTree() + tree1.minLevel = .verbose + + let tree2 = TestTree() + tree2.minLevel = .error + + Canopy.plant(tree1, tree2) + + Canopy.d("Debug message") + Canopy.e("Error message") + + XCTAssertEqual(tree1.logs.count, 2) + XCTAssertEqual(tree2.logs.count, 1) + XCTAssertEqual(tree2.logs.first?.level, .error) + } + + func testLogLocationInfo() throws { + let tree = TestTree() + Canopy.plant(tree) + + Canopy.d("Test message") + + XCTAssertEqual(tree.logs.count, 1) + XCTAssertNotNil(tree.logs.first?.file) + XCTAssertNotNil(tree.logs.first?.function) + XCTAssertNotNil(tree.logs.first?.line) + } + + func testTreeWithTagOverride() throws { + let tree = TestTree() + Canopy.plant(tree) + + tree.tag("CustomTag") + + Canopy.d("Message") + + XCTAssertEqual(tree.logs.count, 1) + XCTAssertEqual(tree.logs.first?.tag, "CustomTag") + } + + func testTreeTagIsClearedAfterUse() throws { + let tree = TestTree() + Canopy.plant(tree) + + tree.tag("Tag1") + Canopy.d("Message 1") + XCTAssertEqual(tree.logs.first?.tag, "Tag1") + + Canopy.d("Message 2") + XCTAssertNil(tree.logs[1].tag) + } + + func testLogLevelComparison() throws { + XCTAssertLessThan(LogLevel.verbose, LogLevel.debug) + XCTAssertLessThan(LogLevel.debug, LogLevel.info) + XCTAssertLessThan(LogLevel.info, LogLevel.warning) + XCTAssertLessThan(LogLevel.warning, LogLevel.error) + } + + func testLogLevelEquality() throws { + XCTAssertEqual(LogLevel.verbose, LogLevel.verbose) + XCTAssertNotEqual(LogLevel.verbose, LogLevel.debug) + } + + func testHighVolumeLogging() throws { + let tree = TestTree() + Canopy.plant(tree) + + let count = 1000 + for i in 0.. Utilities) + - Filter by your app bundle ID + - See structured logs from os.log + +2. **Enable log levels selectively:** + ```swift + #if DEBUG + tree.minLevel = .verbose + #else + tree.minLevel = .error + #endif + ``` + +3. **Use breakpoints to verify logging:** + - Set breakpoints in custom Tree log() methods + - Inspect incoming parameters + - Verify filtering logic + +4. **Profile logging overhead:** + - Use Instruments Time Profiler + - Identify expensive logging calls + - Optimize hot paths + +### Getting Help + +- **GitHub Issues:** [github.com/ding1dingx/Canopy/issues](https://github.com/ding1dingx/Canopy/issues) +- **Examples:** See [Examples/README.md](Examples/README.md) for integration examples +- **Documentation:** [Canopy Wiki](https://github.com/ding1dingx/Canopy/wiki) + ## License See project LICENSE file. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..db694f3 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,429 @@ +# Canopy + +> 🌲 树冠覆盖森林,全面洞察你的 App。 + +轻量级、高性能的 iOS 日志框架,灵感来自 Android 的 Timber。 + +## 特性 + +- **Tree 架构** - 通过可插拔的 Tree 灵活配置日志 +- **性能优化** - Release 模式下如果只用 `DebugTree` 则零开销 +- **iOS 14+ 支持** - 仅使用 Swift 标准库和 Foundation +- **无外部依赖** - 纯 Swift 实现 + +## 快速开始 + +使用 Swift Package Manager 或 CocoaPods 将 Canopy 添加到你的项目: + +```bash +# Swift Package Manager +dependencies: [ + .package(url: "https://github.com/ding1dingx/Canopy.git", from: "0.1.0") +] + +# CocoaPods +pod 'Canopy', '~> 0.1.0' +``` + +在 `AppDelegate` 中初始化: + +```swift +#if DEBUG +Canopy.plant(DebugTree()) +#endif +Canopy.plant(CrashBufferTree(maxSize: 100)) + +// 在应用任何地方使用 +Canopy.v("Verbose message") +Canopy.d("Debug message") +Canopy.i("Info message") +Canopy.w("Warning message") +Canopy.e("Error message") +``` + +## 工作原理 + +### Debug 模式 + +- 所有日志都会打印到控制台 + +### Release 模式 + +- `DebugTree` 的日志**不会**打印 +- 其他 Tree(如 `CrashBufferTree`)的日志**仍然**会打印 +- 如果只种了 `DebugTree`,Release 构建中**零开销** + +## 日志级别 + +| 方法 | 级别 | 用途 | +|------|------|------| +| `Canopy.v()` | Verbose | 详细诊断信息 | +| `Canopy.d()` | Debug | 开发调试信息 | +| `Canopy.i()` | Info | 一般信息 | +| `Canopy.w()` | Warning | 潜在问题 | +| `Canopy.e()` | Error | 错误和失败 | + +## Tree 类型 + +### DebugTree + +只在 Debug 模式打印日志到控制台。 + +```swift +Canopy.plant(DebugTree()) +``` + +### CrashBufferTree + +在内存中保存最近的日志。崩溃时保存到文件用于分析。 + +```swift +let crashTree = CrashBufferTree(maxSize: 100) +Canopy.plant(crashTree) + +// 稍后获取日志 +let logs = crashTree.recentLogs() +``` + +**使用场景:** 非常适合 Release 模式 - 即使控制台日志关闭也能保留崩溃日志。 + +### AsyncTree + +包装任意 Tree,在后台队列记录日志,不阻塞调用者。 + +```swift +let asyncTree = AsyncTree(wrapping: crashTree) +Canopy.plant(asyncTree) +``` + +### 自定义 Tree + +通过继承 `Tree` 创建自己的 Tree: + +```swift +public final class FileTree: Tree { + override func log(priority: LogLevel, tag: String?, message: String, error: Error?) { + // 写入文件 + } +} +``` + +## 带标签的日志 + +为日志添加上下文: + +```swift +Canopy.tag("Network").i("API 请求开始") +Canopy.tag("Database").w("检测到慢查询") +Canopy.tag("Analytics").v("事件已追踪:page_view") +``` + +## 演示应用 + +内置演示展示所有 Canopy 功能: + +| 按钮 | 功能 | +|------|------| +| Verbose/Debug/Info/Warning/Error | 不同日志级别演示 | +| Format Log | 字符串格式化 | +| Tagged Log | 基于上下文的日志 | +| Async Log | 异步日志 | +| View Crash Buffer | 显示缓冲日志 | + +**运行演示:** + +1. 在 Xcode 中打开项目 +2. 选择 iOS 14.0+ 模拟器或真机 +3. Build 并运行 +4. 在 Xcode Console(⌘⇧Y)中查看日志 + +## 要求 + +- iOS 14.0+ +- Swift 5.0+ +- Xcode 12.0+ + +## 最佳实践 + +### 1. 使用适当的日志级别 + +```swift +// ✅ 正确:生产环境使用适当级别 +func processData(_ data: Data) { + Canopy.d("Processing \(data.count) bytes") // 只在 Debug 构建中生效 +} + +// ❌ 避免:生产环境过度使用 verbose 日志 +func processData(_ data: Data) { + Canopy.v("Step 1: Starting") + Canopy.v("Step 2: Parsing") + Canopy.v("Step 3: Validating") + Canopy.v("Step 4: Saving") +} +``` + +### 2. 利用 @autoclosure 提高性能 + +```swift +// ✅ 正确:懒加载字符串 +Canopy.d("Processing item: \(itemName)") // 只有日志启用时才构建字符串 + +// ✅ 更好:使用格式化参数(无字符串插值) +Canopy.d("Processing item: %@", itemName) + +// ❌ 避免:总是构建字符串(有性能开销) +Canopy.d("Processing item: " + itemName) +``` + +### 3. 对昂贵操作使用 AsyncTree + +```swift +// ✅ 正确:用 AsyncTree 包装昂贵操作 +let crashTree = CrashBufferTree(maxSize: 100) +let asyncCrashTree = AsyncTree(wrapping: crashTree) +Canopy.plant(asyncCrashTree) + +// 日志不会阻塞调用线程 +Canopy.d("User logged in") +``` + +### 4. 使用标签进行上下文日志记录 + +```swift +// ✅ 正确:使用标签添加上下文 +class NetworkManager { + private let tag = "Network" + + func makeRequest() { + Canopy.tag(tag).i("Starting request to \(url)") + } + + func handleResponse() { + Canopy.tag(tag).i("Received response: \(statusCode)") + } +} + +// ✅ 更好的方式:通过 CanopyContext 添加标签 +func pushView(_ viewController: UIViewController) { + CanopyContext.push(viewController: viewController) + Canopy.i("View displayed") + CanopyContext.current = nil +} +``` + +### 5. Release 模式配置 + +```swift +// ✅ 推荐:生产环境最小化日志 +#if DEBUG +Canopy.plant(DebugTree()) +#endif + +// 即使在 release 环境也保留崩溃日志 +let crashTree = CrashBufferTree(maxSize: 100) +Canopy.plant(crashTree) + +// 可选:为错误添加远程日志 +#if !DEBUG +let sentryTree = SentryTree(sentry: sentry, minLevel: .error) +Canopy.plant(sentryTree) +#endif +``` + +### 6. 避免常见陷阱 + +```swift +// ❌ 避免:日志中的字符串拼接 +Canopy.d("User: " + username + " logged in") + +// ❌ 避免:日志中使用 String.format(可能导致崩溃) +Canopy.d(String.format("URL is %s", url)) + +// ✅ 正确:使用 Canopy 内置格式化 +Canopy.d("User %@ logged in", username) +Canopy.d("URL is %@", url) + +// ❌ 避免:记录敏感数据 +Canopy.d("Password: %@", password) + +// ✅ 正确:清理或省略敏感数据 +Canopy.d("User %@ logged in (password hidden)", username) +``` + +## 性能分析 + +### 基准测试结果 + +| 操作 | Debug 模式 | Release 模式(仅 DebugTree)| +|------|-------------|---------------------------| +| 日志调用开销 | ~50ns | 0ns(编译器优化掉)| +| 字符串格式化 | ~200ns | 0ns(不执行)| +| Tree 遍历 | ~10ns | 0ns(无 Tree 种植)| + +### 内存影响 + +| 组件 | 内存占用 | +|------|---------| +| Canopy 核心 | ~5KB | +| DebugTree | ~2KB | +| CrashBufferTree(100 条日志)| ~10KB | +| AsyncTree 开销 | ~1KB | + +### 优化技巧 + +1. **使用 @autoclosure** - 只有在日志启用时才构建字符串 +2. **设置适当的 minLevel** - 避免生产环境不必要的工作 +3. **使用 AsyncTree** - 不要为昂贵操作阻塞调用线程 +4. **限制缓冲区大小** - CrashBufferTree 使用 100-500 条日志最优 +5. **避免过度日志记录** - 可能导致性能下降 + +## 故障排查 + +### 常见问题 + +#### 1. 日志不显示在控制台 + +**症状:** +- 日志不显示在 Xcode 控制台 +- 只显示部分日志 + +**解决方案:** +```swift +// 检查是否种植了 Tree +#if DEBUG +Canopy.plant(DebugTree()) // 确保已调用 +#endif + +// 检查日志级别过滤 +let tree = DebugTree() +tree.minLevel = .verbose // 确保级别足够低 + +// 检查 Release 模式是否禁用了 DebugTree +#if DEBUG +// DebugTree 只在 DEBUG 构建中生效 +#endif +``` + +#### 2. 性能问题 + +**症状:** +- 启用日志后应用变慢 +- 主线程阻塞 + +**解决方案:** +```swift +// 1. 对昂贵操作使用 AsyncTree +let asyncTree = AsyncTree(wrapping: crashTree) +Canopy.plant(asyncTree) + +// 2. 生产环境提高 minLevel +tree.minLevel = .error // 只记录错误 + +// 3. 减少日志频率 +// 不要记录每次迭代 +for i in 0..<1000 { + if i % 100 == 0 { + Canopy.d("Progress: %d/1000", i) + } +} +``` + +#### 3. 日志缺少上下文 + +**症状:** +- 无法判断哪个模块记录了日志 +- 日志缺乏源信息 + +**解决方案:** +```swift +// 1. 使用标签 +Canopy.tag("Network").i("Request started") + +// 2. 使用 CanopyContext +#if canImport(UIKit) +CanopyContext.push(viewController: self) +Canopy.i("User action") +#endif + +// 3. 包含相关数据 +Canopy.i("User %@ action: %@", userId, actionType) +``` + +#### 4. 线程安全问题 + +**症状:** +- 从多个线程记录日志时崩溃 +- 日志交错不正确 + +**解决方案:** +```swift +// Canopy 设计上是线程安全的 +// 只需确保不违反线程安全: +// ✅ 正确:线程安全使用 +DispatchQueue.global().async { + Canopy.d("Background task") +} + +// ❌ 避免:在没有锁的情况下共享可变状态 +class BadTree: Tree { + var logs: [String] = [] // 非线程安全! +} +``` + +#### 5. 崩溃日志未保存 + +**症状:** +- 崩溃后找不到 CrashBufferTree 日志 +- 文件不存在 + +**解决方案:** +```swift +// 1. 确保 CrashBufferTree 已种植 +let crashTree = CrashBufferTree(maxSize: 100) +Canopy.plant(crashTree) + +// 2. 检查文件权限 +// 日志保存到 Documents 目录 +// 确保应用有写权限 + +// 3. 在应用终止时刷新 +// CrashBufferTree 在正常退出时自动刷新 +// 手动刷新: +crashTree.flush() +``` + +### 调试技巧 + +1. **使用 Console.app 查看 iOS 日志:** + - 打开 Console.app(应用程序 > 实用工具) + - 按应用 bundle ID 过滤 + - 查看来自 os.log 的结构化日志 + +2. **选择性启用日志级别:** + ```swift + #if DEBUG + tree.minLevel = .verbose + #else + tree.minLevel = .error + #endif + ``` + +3. **使用断点验证日志记录:** + - 在自定义 Tree 的 log() 方法中设置断点 + - 检查传入参数 + - 验证过滤逻辑 + +4. **分析日志开销:** + - 使用 Instruments Time Profiler + - 识别昂贵的日志调用 + - 优化热点路径 + +### 获取帮助 + +- **GitHub Issues:** [github.com/ding1dingx/Canopy/issues](https://github.com/ding1dingx/Canopy/issues) +- **示例:** 查看 [Examples/README.zh-CN.md](Examples/README.zh-CN.md) 了解集成示例 +- **测试指南:** [TESTING.zh-CN.md](TESTING.zh-CN.md) + +## 许可证 + +查看项目 LICENSE 文件。 diff --git a/README_CN.md b/README_CN.md deleted file mode 100644 index eefb2ad..0000000 --- a/README_CN.md +++ /dev/null @@ -1,142 +0,0 @@ -# Canopy - -> 🌲 Canopy:树冠覆盖森林,全面洞察你的 App。 - -轻量级、高性能的 iOS 日志框架,灵感来自 Android 的 Timber。 - -## 特性 - -- **Tree 架构** - 通过可插拔的 Tree 灵活配置日志 -- **性能优化** - Release 模式下如果只用 `DebugTree` 则零开销 -- **iOS 14+ 支持** - 仅使用 Swift 标准库和 Foundation -- **无外部依赖** - 纯 Swift 实现 - -## 快速开始 - -使用 Swift Package Manager 或 CocoaPods 将 Canopy 添加到你的项目: - -```bash -# Swift Package Manager -dependencies: [ - .package(url: "https://github.com/ding1dingx/Canopy.git", from: "0.1.0") -] - -# CocoaPods -pod 'Canopy', '~> 0.1.0' -``` - -在 `AppDelegate` 中初始化: - -```swift -#if DEBUG -Canopy.plant(DebugTree()) -#endif -Canopy.plant(CrashBufferTree(maxSize: 100)) - -// 在应用任何地方使用 -Canopy.v("详细日志") -Canopy.d("调试日志") -Canopy.i("信息日志") -Canopy.w("警告日志") -Canopy.e("错误日志") -``` - -## 工作原理 - -### Debug 模式 -- 所有日志都会打印到控制台 - -### Release 模式 -- `DebugTree` 的日志**不会**打印 -- 其他 Tree(如 `CrashBufferTree`)的日志**仍然**会打印 -- 如果只种了 `DebugTree`,Release 构建中**零开销** - -## 日志级别 - -| 方法 | 级别 | 用途 | -|---------|--------|-------| -| `Canopy.v()` | Verbose | 详细诊断信息 | -| `Canopy.d()` | Debug | 开发调试信息 | -| `Canopy.i()` | Info | 一般信息 | -| `Canopy.w()` | Warning | 潜在问题 | -| `Canopy.e()` | Error | 错误和失败 | - -## Tree 类型 - -### DebugTree -只在 Debug 模式打印日志到控制台。 - -```swift -Canopy.plant(DebugTree()) -``` - -### CrashBufferTree -在内存中保存最近的日志。崩溃时保存到文件用于分析。 - -```swift -let crashTree = CrashBufferTree(maxSize: 100) -Canopy.plant(crashTree) - -// 稍后获取日志 -let logs = crashTree.recentLogs() -``` - -**使用场景:** 非常适合 Release 模式 - 即使控制台日志关闭也能保留崩溃日志。 - -### AsyncTree -包装任意 Tree,在后台队列记录日志,不阻塞调用者。 - -```swift -let asyncTree = AsyncTree(wrapping: crashTree) -Canopy.plant(asyncTree) -``` - -### 自定义 Tree - -通过继承 `Tree` 创建自己的 Tree: - -```swift -public final class FileTree: Tree { - override func log(priority: LogLevel, tag: String?, message: String, error: Error?) { - // 写入文件 - } -} -``` - -## 带标签的日志 - -为日志添加上下文: - -```swift -Canopy.tag("Network").i("API 请求开始") -Canopy.tag("Database").w("检测到慢查询") -Canopy.tag("Analytics").v("事件已追踪:page_view") -``` - -## 演示应用 - -内置演示展示所有 Canopy 功能: - -| 按钮 | 功能 | -|------|------| -| Verbose/Debug/Info/Warning/Error | 不同日志级别演示 | -| Format Log | 字符串格式化 | -| Tagged Log | 基于上下文的日志 | -| Async Log | 异步日志 | -| View Crash Buffer | 显示缓冲日志 | - -**运行演示:** -1. 在 Xcode 中打开 `Canopy.xcodeproj` -2. 选择 iOS 14.0+ 模拟器或真机 -3. Build 并运行 -4. 在 Xcode Console(⌘⇧Y)中查看日志 - -## 要求 - -- iOS 14.0+ -- Swift 5.0+ -- Xcode 12.0+ - -## 许可证 - -查看项目 LICENSE 文件。 diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..efea824 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,117 @@ +# Testing Guide + +A comprehensive guide for testing Canopy logging framework. + +--- + +## Running Tests + +### Swift Package Manager (Recommended) + +```bash +# Run all tests +swift test + +# Run specific test suite +swift test --filter CanopyTests +swift test --filter TreeTests +swift test --filter DebugTreeTests +swift test --filter AsyncTreeTests +swift test --filter CrashBufferTreeTests + +# Verbose output +swift test --verbose + +# Generate code coverage +swift test --enable-code-coverage +``` + +### Xcode + +1. Open `Package.swift` in Xcode +2. Product → Scheme → Edit Scheme... +3. Select **Canopy** scheme → **Test** action +4. Press `⌘U` to run tests + +--- + +## Test Suites + +| Suite | Tests | Description | +|-------|-------|-------------| +| CanopyTests | 24 | Core logging functionality | +| TreeTests | 12 | Tree base class | +| DebugTreeTests | 6 | DebugTree functionality | +| AsyncTreeTests | 8 | AsyncTree functionality | +| CrashBufferTreeTests | 6 | CrashBufferTree functionality | + +**Total: 56 tests** + +--- + +## Code Quality + +### SwiftLint + +```bash +# Install +brew install swiftlint + +# Run lint +swiftlint + +# Auto-fix issues +swiftlint --autocorrect +``` + +Configuration: [`.swiftlint.yml`](.swiftlint.yml) + +--- + +## CI/CD + +Example GitHub Actions workflow: + +```yaml +name: CI +on: [push, pull_request] +jobs: + test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Swift + uses: swift-actions/setup-swift@v1 + - name: Run tests + run: swift test + - name: Run SwiftLint + run: brew install swiftlint && swiftlint +``` + +--- + +## Troubleshooting + +### "Unable to find module 'XCTest'" + +```bash +# Regenerate Xcode project +rm -rf *.xcodeproj *.xcworkspace +open Package.swift +``` + +### Tests not running + +```bash +# Clean build cache +swift build clean +swift test +``` + +--- + +## Related + +- [Contributing Guide](CONTRIBUTING.md) +- [README](README.md) +- [Examples](Examples/README.md) diff --git a/TESTING.zh-CN.md b/TESTING.zh-CN.md new file mode 100644 index 0000000..04f656a --- /dev/null +++ b/TESTING.zh-CN.md @@ -0,0 +1,117 @@ +# 测试指南 + +Canopy 日志框架的测试指南。 + +--- + +## 运行测试 + +### Swift Package Manager(推荐) + +```bash +# 运行所有测试 +swift test + +# 运行特定测试套件 +swift test --filter CanopyTests +swift test --filter TreeTests +swift test --filter DebugTreeTests +swift test --filter AsyncTreeTests +swift test --filter CrashBufferTreeTests + +# 详细输出 +swift test --verbose + +# 生成代码覆盖率 +swift test --enable-code-coverage +``` + +### Xcode + +1. 在 Xcode 中打开 `Package.swift` +2. Product → Scheme → Edit Scheme... +3. 选择 **Canopy** scheme → **Test** 操作 +4. 按 `⌘U` 运行测试 + +--- + +## 测试套件 + +| 套件 | 测试数 | 描述 | +|-------|-------|------| +| CanopyTests | 24 | 核心日志功能 | +| TreeTests | 12 | Tree 基类 | +| DebugTreeTests | 6 | DebugTree 功能 | +| AsyncTreeTests | 8 | AsyncTree 功能 | +| CrashBufferTreeTests | 6 | CrashBufferTree 功能 | + +**总计:56 个测试** + +--- + +## 代码质量 + +### SwiftLint + +```bash +# 安装 +brew install swiftlint + +# 运行检查 +swiftlint + +# 自动修复 +swiftlint --autocorrect +``` + +配置:[`.swiftlint.yml`](.swiftlint.yml) + +--- + +## CI/CD + +GitHub Actions 示例: + +```yaml +name: CI +on: [push, pull_request] +jobs: + test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Swift + uses: swift-actions/setup-swift@v1 + - name: 运行测试 + run: swift test + - name: 运行 SwiftLint + run: brew install swiftlint && swiftlint +``` + +--- + +## 常见问题 + +### "Unable to find module 'XCTest'" + +```bash +# 重新生成 Xcode 项目 +rm -rf *.xcodeproj *.xcworkspace +open Package.swift +``` + +### 测试无法运行 + +```bash +# 清理构建缓存 +swift build clean +swift test +``` + +--- + +## 相关文档 + +- [贡献指南](CONTRIBUTING.zh-CN.md) +- [自述文件](README.zh-CN.md) +- [示例](Examples/README.zh-CN.md)