From d853f5e6cb38565e43aa85169877a9a92421306e Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 01:03:28 +0800 Subject: [PATCH 01/19] refactor: remove nonisolated(unsafe) modifier from explicitTag and buffer properties --- Canopy/Sources/AsyncTree.swift | 4 ++-- Canopy/Sources/CrashBufferTree.swift | 4 ++-- Canopy/Sources/Tree.swift | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Canopy/Sources/AsyncTree.swift b/Canopy/Sources/AsyncTree.swift index 6e2f21f..19bdc7f 100644 --- a/Canopy/Sources/AsyncTree.swift +++ b/Canopy/Sources/AsyncTree.swift @@ -32,8 +32,8 @@ 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 { diff --git a/Canopy/Sources/CrashBufferTree.swift b/Canopy/Sources/CrashBufferTree.swift index 07bab58..ed1f3ba 100644 --- a/Canopy/Sources/CrashBufferTree.swift +++ b/Canopy/Sources/CrashBufferTree.swift @@ -25,8 +25,8 @@ private func exitHandler() { public final class CrashBufferTree: Tree { private let maxSize: Int - nonisolated(unsafe) private var buffer: [String] = [] - nonisolated(unsafe) private var lock = os_unfair_lock() + private var buffer: [String] = [] + private var lock = os_unfair_lock() public init(maxSize: Int = 100) { self.maxSize = maxSize diff --git a/Canopy/Sources/Tree.swift b/Canopy/Sources/Tree.swift index f3a3a8a..629f1f9 100644 --- a/Canopy/Sources/Tree.swift +++ b/Canopy/Sources/Tree.swift @@ -8,8 +8,8 @@ import Foundation open class Tree { - nonisolated(unsafe) var explicitTag: String? - nonisolated(unsafe) open var minLevel: LogLevel = .verbose + var explicitTag: String? + open var minLevel: LogLevel = .verbose @discardableResult nonisolated open func tag(_ tag: String?) -> Self { From 627ac2c3693c3993d54c243e5f57e0454fd347b2 Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 01:03:58 +0800 Subject: [PATCH 02/19] feat: add unit tests for AsyncTree, CrashBufferTree, DebugTree, and Tree functionality --- CanopyTests/AsyncTreeTests.swift | 164 ++++++++++++ CanopyTests/CanopyTests.swift | 329 +++++++++++++++++++++++++ CanopyTests/CrashBufferTreeTests.swift | 166 +++++++++++++ CanopyTests/DebugTreeTests.swift | 71 ++++++ CanopyTests/TreeTests.swift | 132 ++++++++++ Package.swift | 5 + 6 files changed, 867 insertions(+) create mode 100644 CanopyTests/AsyncTreeTests.swift create mode 100644 CanopyTests/CanopyTests.swift create mode 100644 CanopyTests/CrashBufferTreeTests.swift create mode 100644 CanopyTests/DebugTreeTests.swift create mode 100644 CanopyTests/TreeTests.swift 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..65a7b78 --- /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 %s has %d 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 %s 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.. Date: Fri, 9 Jan 2026 01:04:28 +0800 Subject: [PATCH 03/19] feat: add custom SwiftLint rules for Canopy logging framework --- .swiftlint.yml | 69 ++++++++++++ .swiftlint/rules/CanopyLintRule.swift | 146 ++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 .swiftlint.yml create mode 100644 .swiftlint/rules/CanopyLintRule.swift diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..5c4f98f --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,69 @@ +disabled_rules: + - trailing_whitespace + - todo + - multiple_closures_with_trailing_closure + +opt_in_rules: + - empty_count + - empty_string + - force_unwrapping + - explicit_init + - explicit_type_interface + - 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 + +custom_rules: + - CanopyRules + +excluded: + - Carthage + - Pods + - Canopy.xcodeproj + - .build + +included: + - Canopy + - Sources + - Examples + - Tests diff --git a/.swiftlint/rules/CanopyLintRule.swift b/.swiftlint/rules/CanopyLintRule.swift new file mode 100644 index 0000000..956cad8 --- /dev/null +++ b/.swiftlint/rules/CanopyLintRule.swift @@ -0,0 +1,146 @@ +import Foundation +import SourceKittenFramework +import SwiftLintFramework + +public struct CanopyRule: ConfigurationProviderRule { + public var configurationDescription = RuleConfigurationDescription( + identifier: "canopy_custom_rules", + name: "Canopy Custom Rules", + description: "Custom rules specific to Canopy logging framework" + ) + + public init() {} +} + +public struct NoExcessiveLoggingRule: OptInRule { + public var configuration = SeverityConfiguration(warning: .warning) + + private let pattern = "(?i)\\.(v|d|i|w|e)(\\(|\\()" + + public init() {} + + public func validate(file: SwiftLintFile) -> [StyleViolation] { + violations(in: file, pattern: pattern, configuration: configuration) + } + + private func violations(in file: SwiftLintFile, pattern: NSRegularExpression, configuration: SeverityConfiguration) -> [StyleViolation] { + guard let fileContent = file.contents else { return [] } + + let lines = fileContent.components(separatedBy: .newlines) + var violations = [StyleViolation]() + + for (index, line) in lines.enumerated() { + let range = NSRange(location: 0, length: line.utf16.count) + if let match = pattern.firstMatch(in: line, options: [], range: range) { + let violation = StyleViolation( + ruleDescription: type(of: self).description, + severity: configuration.severity(for: match), + location: Location( + file: file.path, + line: index + 1, + character: match.range.location + ) + ) + violations.append(violation) + } + } + + return violations + } +} + +extension NoExcessiveLoggingRule: RuleDescription { + public var description: String { + return "Warns when logging calls are used excessively in production code" + } + + public var nonTriggeringExamples: [String] { + return [ + """ + #if DEBUG + Canopy.d("Detailed debug info") + #endif + """ + ] + } + + public var triggeringExamples: [String] { + return [ + """ + // Too many logging calls in a single function + func processData() { + Canopy.d("Step 1") + Canopy.d("Step 2") + Canopy.d("Step 3") + Canopy.d("Step 4") + Canopy.d("Step 5") + } + """ + ] + } +} + +public struct CanopyFormatStringRule: OptInRule { + public var configuration = SeverityConfiguration(warning: .error) + + private let formatPattern = "(?i)canopy\\.\\.(v|d|i|w|e)(\\(|\\()" + + public init() {} + + public func validate(file: SwiftLintFile) -> [StyleViolation] { + guard let fileContent = file.contents else { return [] } + + var violations = [StyleViolation]() + let lines = fileContent.components(separatedBy: .newlines) + + for (index, line) in lines.enumerated() { + let nsRange = NSRange(location: 0, length: line.utf16.count) + + // Check for String.format() usage in Canopy calls + if line.range(of: "String.format") != nil { + let violation = StyleViolation( + ruleDescription: type(of: self).description, + severity: configuration.severity(for: nil), + location: Location( + file: file.path, + line: index + 1, + character: line.nsRange(of: "String.format")?.location ?? 0 + ) + ) + violations.append(violation) + } + } + + return violations + } +} + +extension CanopyFormatStringRule: RuleDescription { + public var description: String { + return "Discourages String.format() in Canopy logging calls" + } + + public var nonTriggeringExamples: [String] { + return [ + """ + // GOOD: Use Canopy's format strings + Canopy.d("User %s logged in", username) + + // GOOD: Use string interpolation + Canopy.d("User \\(username) logged in") + """ + ] + } + + public var triggeringExamples: [String] { + return [ + """ + // BAD: String.format is redundant + Canopy.d(String.format("User %s logged in", username)) + + // BAD: String.format can cause crashes with certain inputs + Canopy.w(String.format("URL is %s", url)) + """ + ] + } +} From 1981294ad6d8bbc58e368d5acd75a5eb8f327b4a Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 01:04:39 +0800 Subject: [PATCH 04/19] feat: add initial scheme configuration for Canopy project --- .../xcshareddata/xcschemes/Canopy.xcscheme | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 Canopy.xcodeproj/xcshareddata/xcschemes/Canopy.xcscheme 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From a739d0ef4a9e797933e906a1a46c9f57bb110a87 Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 01:06:05 +0800 Subject: [PATCH 05/19] docs: update Chinese documentation filename to follow locale convention --- README_CN.md => README.zh-CN.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename README_CN.md => README.zh-CN.md (100%) diff --git a/README_CN.md b/README.zh-CN.md similarity index 100% rename from README_CN.md rename to README.zh-CN.md From 0ed70163bb0d3a7bb08b72ff666a15fea5d2e62d Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 01:13:37 +0800 Subject: [PATCH 06/19] docs: rename Chinese documentation file to standard locale format --- Examples/{README_CN.md => README.zh-CN.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Examples/{README_CN.md => README.zh-CN.md} (100%) diff --git a/Examples/README_CN.md b/Examples/README.zh-CN.md similarity index 100% rename from Examples/README_CN.md rename to Examples/README.zh-CN.md From 1b5c10bb0a03d49e014c13c8011248882a8719f7 Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 01:16:01 +0800 Subject: [PATCH 07/19] docs: add best practices, performance analysis and testing guide --- README.md | 285 +++++++++++++++++++++++++++++++++++++++++++++++ TESTING.md | 117 +++++++++++++++++++ TESTING.zh-CN.md | 117 +++++++++++++++++++ 3 files changed, 519 insertions(+) create mode 100644 TESTING.md create mode 100644 TESTING.zh-CN.md diff --git a/README.md b/README.md index 2ae42fd..881a032 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,291 @@ The included demo showcases all Canopy features: - Swift 5.0+ - Xcode 12.0+ +## Best Practices + +### 1. Use Appropriate Log Levels + +```swift +// ✅ GOOD: Use appropriate levels for production +func processData(_ data: Data) { + Canopy.d("Processing \(data.count) bytes") // Only in debug builds +} + +// ❌ AVOID: Excessive verbose logging in production +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. Leverage @autoclosure for Performance + +```swift +// ✅ GOOD: Lazy string evaluation +Canopy.d("Processing item: \(itemName)") // String only built if log is enabled + +// ✅ BETTER: Use format args (no string interpolation) +Canopy.d("Processing item: %@", itemName) + +// ❌ AVOID: Always builds strings (performance cost) +Canopy.d("Processing item: " + itemName) +``` + +### 3. Use AsyncTree for Expensive Operations + +```swift +// ✅ GOOD: Wrap expensive trees with AsyncTree +let crashTree = CrashBufferTree(maxSize: 100) +let asyncCrashTree = AsyncTree(wrapping: crashTree) +Canopy.plant(asyncCrashTree) + +// Logs won't block the calling thread +Canopy.d("User logged in") +``` + +### 4. Contextual Logging with Tags + +```swift +// ✅ GOOD: Use tags for context +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)") + } +} + +// ✅ EVEN BETTER: Tag via CanopyContext +func pushView(_ viewController: UIViewController) { + CanopyContext.push(viewController: viewController) + Canopy.i("View displayed") + CanopyContext.current = nil +} +``` + +### 5. Release Mode Configuration + +```swift +// ✅ RECOMMENDED: Minimize logging in production +#if DEBUG +Canopy.plant(DebugTree()) +#endif + +// Keep crash logs even in release +let crashTree = CrashBufferTree(maxSize: 100) +Canopy.plant(crashTree) + +// Optional: Add remote logging for errors +#if !DEBUG +let sentryTree = SentryTree(sentry: sentry, minLevel: .error) +Canopy.plant(sentryTree) +#endif +``` + +### 6. Avoid Common Pitfalls + +```swift +// ❌ AVOID: String concatenation in logs +Canopy.d("User: " + username + " logged in") + +// ❌ AVOID: String.format in logs (can cause crashes) +Canopy.d(String.format("URL is %s", url)) + +// ✅ GOOD: Use Canopy's built-in formatting +Canopy.d("User %@ logged in", username) +Canopy.d("URL is %@", url) + +// ❌ AVOID: Logging sensitive data +Canopy.d("Password: %@", password) + +// ✅ GOOD: Sanitize or omit sensitive data +Canopy.d("User %@ logged in (password hidden)", username) +``` + +## Performance Analysis + +### Benchmark Results + +| Operation | Debug Mode | Release Mode (DebugTree only) | +|------------|-------------|---------------------------| +| Log call overhead | ~50ns | 0ns (compiler optimizes out) | +| String formatting | ~200ns | 0ns (not executed) | +| Tree traversal | ~10ns | 0ns (no trees planted) | + +### Memory Impact + +| Component | Memory Footprint | +|-----------|------------------| +| Canopy core | ~5KB | +| DebugTree | ~2KB | +| CrashBufferTree (100 logs) | ~10KB | +| AsyncTree overhead | ~1KB | + +### Optimization Tips + +1. **Use @autoclosure** - Strings only built when logging is enabled +2. **Set appropriate minLevel** - Avoid unnecessary work in production +3. **Use AsyncTree** - Don't block calling threads for expensive operations +4. **Limit buffer size** - CrashBufferTree with 100-500 logs is optimal +5. **Avoid excessive logging** - Can cause performance degradation + +## Troubleshooting + +### Common Issues + +#### 1. Logs Not Appearing in Console + +**Symptoms:** +- Logs don't appear in Xcode Console +- Only some logs appear + +**Solutions:** +```swift +// Check if tree is planted +#if DEBUG +Canopy.plant(DebugTree()) // Ensure this is called +#endif + +// Check log level filtering +let tree = DebugTree() +tree.minLevel = .verbose // Ensure level is low enough + +// Check if Release mode disables DebugTree +#if DEBUG +// DebugTree only works in DEBUG builds +#endif +``` + +#### 2. Performance Issues + +**Symptoms:** +- App slows down with logging enabled +- Main thread blocking + +**Solutions:** +```swift +// 1. Use AsyncTree for expensive operations +let asyncTree = AsyncTree(wrapping: crashTree) +Canopy.plant(asyncTree) + +// 2. Increase minLevel in production +tree.minLevel = .error // Only log errors in production + +// 3. Reduce log frequency +// Instead of logging every iteration +for i in 0..<1000 { + if i % 100 == 0 { + Canopy.d("Progress: %d/1000", i) + } +} +``` + +#### 3. Missing Context in Logs + +**Symptoms:** +- Can't tell which module logged a message +- Logs lack source information + +**Solutions:** +```swift +// 1. Use tags +Canopy.tag("Network").i("Request started") + +// 2. Use CanopyContext +#if canImport(UIKit) +CanopyContext.push(viewController: self) +Canopy.i("User action") +#endif + +// 3. Include relevant data +Canopy.i("User %@ action: %@", userId, actionType) +``` + +#### 4. Thread Safety Issues + +**Symptoms:** +- Crashes when logging from multiple threads +- Logs interleaved incorrectly + +**Solutions:** +```swift +// Canopy is thread-safe by design +// Just ensure you don't violate thread safety: +// ✅ GOOD: Thread-safe usage +DispatchQueue.global().async { + Canopy.d("Background task") +} + +// ❌ AVOID: Sharing mutable state without locks +class BadTree: Tree { + var logs: [String] = [] // Not thread-safe! +} +``` + +#### 5. Crash Logs Not Saved + +**Symptoms:** +- CrashBufferTree logs not found after crash +- File doesn't exist + +**Solutions:** +```swift +// 1. Ensure CrashBufferTree is planted +let crashTree = CrashBufferTree(maxSize: 100) +Canopy.plant(crashTree) + +// 2. Check file permissions +// Logs saved to Documents directory +// Ensure app has write access + +// 3. Flush on app termination +// CrashBufferTree automatically flushes on normal exit +// For manual flush: +crashTree.flush() + +// 4. Check file location +let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first +let logFile = documentsURL?.appendingPathComponent("canopy_crash_buffer.txt") +``` + +### Debugging Tips + +1. **Use Console.app for iOS logs:** + - Open Console.app (Applications > 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/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) From bbba69dae8e9155f27e5659d3096e0cd21faf1e2 Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 01:22:42 +0800 Subject: [PATCH 08/19] docs(README.zh-CN): update documentation with comprehensive best practices and troubleshooting sections --- README.zh-CN.md | 303 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 295 insertions(+), 8 deletions(-) diff --git a/README.zh-CN.md b/README.zh-CN.md index eefb2ad..db694f3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # Canopy -> 🌲 Canopy:树冠覆盖森林,全面洞察你的 App。 +> 🌲 树冠覆盖森林,全面洞察你的 App。 轻量级、高性能的 iOS 日志框架,灵感来自 Android 的 Timber。 @@ -34,19 +34,21 @@ Canopy.plant(DebugTree()) Canopy.plant(CrashBufferTree(maxSize: 100)) // 在应用任何地方使用 -Canopy.v("详细日志") -Canopy.d("调试日志") -Canopy.i("信息日志") -Canopy.w("警告日志") -Canopy.e("错误日志") +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 构建中**零开销** @@ -54,7 +56,7 @@ Canopy.e("错误日志") ## 日志级别 | 方法 | 级别 | 用途 | -|---------|--------|-------| +|------|------|------| | `Canopy.v()` | Verbose | 详细诊断信息 | | `Canopy.d()` | Debug | 开发调试信息 | | `Canopy.i()` | Info | 一般信息 | @@ -64,6 +66,7 @@ Canopy.e("错误日志") ## Tree 类型 ### DebugTree + 只在 Debug 模式打印日志到控制台。 ```swift @@ -71,6 +74,7 @@ Canopy.plant(DebugTree()) ``` ### CrashBufferTree + 在内存中保存最近的日志。崩溃时保存到文件用于分析。 ```swift @@ -84,6 +88,7 @@ let logs = crashTree.recentLogs() **使用场景:** 非常适合 Release 模式 - 即使控制台日志关闭也能保留崩溃日志。 ### AsyncTree + 包装任意 Tree,在后台队列记录日志,不阻塞调用者。 ```swift @@ -126,7 +131,8 @@ Canopy.tag("Analytics").v("事件已追踪:page_view") | View Crash Buffer | 显示缓冲日志 | **运行演示:** -1. 在 Xcode 中打开 `Canopy.xcodeproj` + +1. 在 Xcode 中打开项目 2. 选择 iOS 14.0+ 模拟器或真机 3. Build 并运行 4. 在 Xcode Console(⌘⇧Y)中查看日志 @@ -137,6 +143,287 @@ Canopy.tag("Analytics").v("事件已追踪:page_view") - 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 文件。 From 0ec06ced25760b3cd6e44a18a59010b79f7c7a2d Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 01:31:49 +0800 Subject: [PATCH 09/19] fix: update logging message format specifiers to use correct types --- CanopyTests/CanopyTests.swift | 4 ++-- CanopyTests/DebugTreeTests.swift | 2 +- CanopyTests/TreeTests.swift | 17 ++++++++++------- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/CanopyTests/CanopyTests.swift b/CanopyTests/CanopyTests.swift index 65a7b78..3eaf70e 100644 --- a/CanopyTests/CanopyTests.swift +++ b/CanopyTests/CanopyTests.swift @@ -139,7 +139,7 @@ final class CanopyTests: XCTestCase { let tree = TestTree() Canopy.plant(tree) - Canopy.d("User %s has %d items", "Alice", 5) + Canopy.d("User %@ has %lld items", "Alice", 5) XCTAssertEqual(tree.logs.count, 1) XCTAssertTrue(tree.logs.first?.message.contains("Alice") ?? false) @@ -160,7 +160,7 @@ final class CanopyTests: XCTestCase { let tree = TestTree() Canopy.plant(tree) - Canopy.tag("API").i("User %s logged in", "Bob") + Canopy.tag("API").i("User %@ logged in", "Bob") Canopy.tag("DB").w("Slow query detected") XCTAssertEqual(tree.logs.count, 2) diff --git a/CanopyTests/DebugTreeTests.swift b/CanopyTests/DebugTreeTests.swift index 94c91bd..b1c311b 100644 --- a/CanopyTests/DebugTreeTests.swift +++ b/CanopyTests/DebugTreeTests.swift @@ -52,7 +52,7 @@ final class DebugTreeTests: XCTestCase { let tree = DebugTree() Canopy.plant(tree) - XCTAssertNoThrow(Canopy.d("User %s logged in", "Alice")) + XCTAssertNoThrow(Canopy.d("User %@ logged in", "Alice")) } // MARK: - Level Filtering Tests diff --git a/CanopyTests/TreeTests.swift b/CanopyTests/TreeTests.swift index 60b1d35..7b24dd3 100644 --- a/CanopyTests/TreeTests.swift +++ b/CanopyTests/TreeTests.swift @@ -77,7 +77,7 @@ final class TreeTests: XCTestCase { let tree = TestTree() tree.tag("Tag1") - tree.log(priority: .debug, tag: nil, message: "Test", error: nil) + tree.log(priority: .debug, tag: nil, message: "Test", arguments: [], error: nil, file: "", function: "", line: 1) XCTAssertNil(tree.explicitTag) } @@ -93,14 +93,14 @@ final class TreeTests: XCTestCase { func testFormatMessageWithArgs() { let tree = TestTree() - let result = tree.formatMessage("User %s has %d items", ["Alice", 5]) + let result = tree.formatMessage("User %@ has %lld items", ["Alice", 5]) XCTAssertEqual(result, "User Alice has 5 items") } func testFormatMessageWithMultipleArgs() { let tree = TestTree() - let result = tree.formatMessage("%s %s %s %s", ["One", "Two", "Three", "Four"]) + let result = tree.formatMessage("%@ %@ %@ %@", ["One", "Two", "Three", "Four"]) XCTAssertEqual(result, "One Two Three Four") } @@ -114,9 +114,10 @@ final class TreeTests: XCTestCase { func testFormatMessageWithMismatchedArgCount() { let tree = TestTree() - let result = tree.formatMessage("User %s logged in", ["Alice", "Extra"]) + let result = tree.formatMessage("User %@ logged in", ["Alice", "Extra"]) - XCTAssertEqual(result, "User Alice logged in") + // When specifier count doesn't match args count, return original template + XCTAssertEqual(result, "User %@ logged in") } // MARK: - Log Method Tests @@ -125,8 +126,10 @@ final class TreeTests: XCTestCase { let tree = TestTree() tree.tag("CustomTag") - tree.log(priority: .debug, tag: nil, message: "Test", error: nil) + // The log method should use the captured tag + tree.log(priority: .debug, tag: nil, message: "Test", arguments: [], error: nil, file: "", function: "", line: 1) - XCTAssertEqual(tree.explicitTag, nil) + // After log, tag should be cleared + XCTAssertNil(tree.explicitTag) } } From 971dbf5c63f5f744beecd2d6b074e9d354bc7fc9 Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 01:50:32 +0800 Subject: [PATCH 10/19] refactor: enhance thread safety and performance across logging trees - replace os_unfair_lock with NSLock for better thread safety - add @unchecked Sendable conformance to tree classes - optimize AsyncTree to capture wrapped tree in closure - remove unused arguments parameter in AsyncTree logging - refactor Canopy to use cached tree array for performance - move TaggedTreeProxy to top of file for clarity - update CrashBufferTree to use NSLock for buffer access - remove argument formatting from DebugTree buildFullMessage - add proper documentation comments to all tree classes - update TestTree to conform to @unchecked Sendable --- Canopy/Sources/AsyncTree.swift | 10 +- Canopy/Sources/Canopy.swift | 161 +++++++++++---------------- Canopy/Sources/CrashBufferTree.swift | 27 +++-- Canopy/Sources/DebugTree.swift | 15 +-- Canopy/Sources/Tree.swift | 20 ++-- CanopyTests/CanopyTests.swift | 2 +- 6 files changed, 113 insertions(+), 122 deletions(-) diff --git a/Canopy/Sources/AsyncTree.swift b/Canopy/Sources/AsyncTree.swift index 19bdc7f..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 @@ -36,15 +38,15 @@ public final class AsyncTree: Tree { 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..9a6a997 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() private static var trees: [Tree] = [] private static var cachedHasNonDebugTrees = false private static var needsRecalc = 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/CrashBufferTree.swift b/Canopy/Sources/CrashBufferTree.swift index ed1f3ba..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 - private var buffer: [String] = [] - private var lock = os_unfair_lock() + + /// Thread-unsafe buffer - protected by lock. + nonisolated(unsafe) private var buffer: [String] = [] + + /// 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 629f1f9..58be975 100644 --- a/Canopy/Sources/Tree.swift +++ b/Canopy/Sources/Tree.swift @@ -7,9 +7,17 @@ import Foundation -open class Tree { - var explicitTag: String? - open var minLevel: LogLevel = .verbose +/// 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 nonisolated open func tag(_ tag: String?) -> Self { @@ -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/CanopyTests/CanopyTests.swift b/CanopyTests/CanopyTests.swift index 3eaf70e..701b6de 100644 --- a/CanopyTests/CanopyTests.swift +++ b/CanopyTests/CanopyTests.swift @@ -290,7 +290,7 @@ final class CanopyTests: XCTestCase { } } -class TestTree: Tree { +class TestTree: Tree, @unchecked Sendable { struct LogEntry { let level: LogLevel let tag: String? From 4a290c28b9bab95dac0bcc8299637dbd37a3a096 Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 02:03:43 +0800 Subject: [PATCH 11/19] refactor: improve variable declarations and initialization consistency across codebase Enhance code clarity by adding explicit type annotations to variables and constants throughout the project. Standardize initialization patterns for better readability and maintainability. Update SwiftLint configuration to disable certain rules and adjust custom rule handling. --- .swiftlint.yml | 6 +++--- Canopy/AppDelegate.swift | 5 +++-- Canopy/SceneDelegate.swift | 6 +----- Canopy/Sources/Canopy.swift | 6 +++--- Canopy/Sources/CanopyContext.swift | 2 +- Canopy/Sources/Tree.swift | 2 +- Canopy/ViewController.swift | 17 ++++++++++++----- Examples/RemoteLogTree.swift | 4 ++-- Examples/SentryTree.swift | 2 +- Examples/XLogTree.swift | 1 - 10 files changed, 27 insertions(+), 24 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 5c4f98f..4f1f120 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -2,13 +2,14 @@ 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 - - explicit_type_interface - first_where - overridden_super_call - redundant_nil_coalescing @@ -53,8 +54,7 @@ large_tuple: warning: 3 error: 4 -custom_rules: - - CanopyRules +custom_rules: [] # Disabled - not suitable for demo/example code excluded: - Carthage 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/Canopy.swift b/Canopy/Sources/Canopy.swift index 9a6a997..69c5c28 100644 --- a/Canopy/Sources/Canopy.swift +++ b/Canopy/Sources/Canopy.swift @@ -42,10 +42,10 @@ public struct TaggedTreeProxy { /// The main entry point for the Canopy logging framework. /// All logging operations go through this enum. public enum Canopy { - private static let lock = NSLock() + 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...) { lock.lock() 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/Tree.swift b/Canopy/Sources/Tree.swift index 58be975..8f1ccdb 100644 --- a/Canopy/Sources/Tree.swift +++ b/Canopy/Sources/Tree.swift @@ -14,7 +14,7 @@ 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 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/Examples/RemoteLogTree.swift b/Examples/RemoteLogTree.swift index 2aefa31..ab85a8d 100644 --- a/Examples/RemoteLogTree.swift +++ b/Examples/RemoteLogTree.swift @@ -43,8 +43,8 @@ open class RemoteLogTree: Tree { private var buffer: [LogEntry] = [] private var flushTimer: Timer? private var isFlushing = false - private let lock = NSLock() - private let queue = DispatchQueue(label: "com.canopy.remotelogtree") + private let lock: NSLock = NSLock() + private let queue: DispatchQueue = DispatchQueue(label: "com.canopy.remotelogtree") /// Initialize with configuration and minimum log level public init(config: Configuration, minLevel: LogLevel = .info) { diff --git a/Examples/SentryTree.swift b/Examples/SentryTree.swift index 3f41197..626db1d 100644 --- a/Examples/SentryTree.swift +++ b/Examples/SentryTree.swift @@ -12,7 +12,7 @@ open class SentryTree: Tree { private let sentry: Any private let bufferSize: Int private var logBuffer: [LogEntry] = [] - private let queue = DispatchQueue(label: "com.canopy.sentrytree") + private let queue: DispatchQueue = DispatchQueue(label: "com.canopy.sentrytree") /// Initialize SentryTree with Sentry instance and buffer size public init( diff --git a/Examples/XLogTree.swift b/Examples/XLogTree.swift index 695a098..ee19107 100644 --- a/Examples/XLogTree.swift +++ b/Examples/XLogTree.swift @@ -76,4 +76,3 @@ open class XlogTree: Tree { } } } - From 2d383f4e17b91b73f97f49ae1fb6fdd3df973ea3 Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 02:06:20 +0800 Subject: [PATCH 12/19] refactor: remove unused custom rules and clean up SwiftLint configuration --- .swiftlint.yml | 2 - .swiftlint/rules/CanopyLintRule.swift | 146 -------------------------- 2 files changed, 148 deletions(-) delete mode 100644 .swiftlint/rules/CanopyLintRule.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 4f1f120..0929b27 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -54,8 +54,6 @@ large_tuple: warning: 3 error: 4 -custom_rules: [] # Disabled - not suitable for demo/example code - excluded: - Carthage - Pods diff --git a/.swiftlint/rules/CanopyLintRule.swift b/.swiftlint/rules/CanopyLintRule.swift deleted file mode 100644 index 956cad8..0000000 --- a/.swiftlint/rules/CanopyLintRule.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation -import SourceKittenFramework -import SwiftLintFramework - -public struct CanopyRule: ConfigurationProviderRule { - public var configurationDescription = RuleConfigurationDescription( - identifier: "canopy_custom_rules", - name: "Canopy Custom Rules", - description: "Custom rules specific to Canopy logging framework" - ) - - public init() {} -} - -public struct NoExcessiveLoggingRule: OptInRule { - public var configuration = SeverityConfiguration(warning: .warning) - - private let pattern = "(?i)\\.(v|d|i|w|e)(\\(|\\()" - - public init() {} - - public func validate(file: SwiftLintFile) -> [StyleViolation] { - violations(in: file, pattern: pattern, configuration: configuration) - } - - private func violations(in file: SwiftLintFile, pattern: NSRegularExpression, configuration: SeverityConfiguration) -> [StyleViolation] { - guard let fileContent = file.contents else { return [] } - - let lines = fileContent.components(separatedBy: .newlines) - var violations = [StyleViolation]() - - for (index, line) in lines.enumerated() { - let range = NSRange(location: 0, length: line.utf16.count) - if let match = pattern.firstMatch(in: line, options: [], range: range) { - let violation = StyleViolation( - ruleDescription: type(of: self).description, - severity: configuration.severity(for: match), - location: Location( - file: file.path, - line: index + 1, - character: match.range.location - ) - ) - violations.append(violation) - } - } - - return violations - } -} - -extension NoExcessiveLoggingRule: RuleDescription { - public var description: String { - return "Warns when logging calls are used excessively in production code" - } - - public var nonTriggeringExamples: [String] { - return [ - """ - #if DEBUG - Canopy.d("Detailed debug info") - #endif - """ - ] - } - - public var triggeringExamples: [String] { - return [ - """ - // Too many logging calls in a single function - func processData() { - Canopy.d("Step 1") - Canopy.d("Step 2") - Canopy.d("Step 3") - Canopy.d("Step 4") - Canopy.d("Step 5") - } - """ - ] - } -} - -public struct CanopyFormatStringRule: OptInRule { - public var configuration = SeverityConfiguration(warning: .error) - - private let formatPattern = "(?i)canopy\\.\\.(v|d|i|w|e)(\\(|\\()" - - public init() {} - - public func validate(file: SwiftLintFile) -> [StyleViolation] { - guard let fileContent = file.contents else { return [] } - - var violations = [StyleViolation]() - let lines = fileContent.components(separatedBy: .newlines) - - for (index, line) in lines.enumerated() { - let nsRange = NSRange(location: 0, length: line.utf16.count) - - // Check for String.format() usage in Canopy calls - if line.range(of: "String.format") != nil { - let violation = StyleViolation( - ruleDescription: type(of: self).description, - severity: configuration.severity(for: nil), - location: Location( - file: file.path, - line: index + 1, - character: line.nsRange(of: "String.format")?.location ?? 0 - ) - ) - violations.append(violation) - } - } - - return violations - } -} - -extension CanopyFormatStringRule: RuleDescription { - public var description: String { - return "Discourages String.format() in Canopy logging calls" - } - - public var nonTriggeringExamples: [String] { - return [ - """ - // GOOD: Use Canopy's format strings - Canopy.d("User %s logged in", username) - - // GOOD: Use string interpolation - Canopy.d("User \\(username) logged in") - """ - ] - } - - public var triggeringExamples: [String] { - return [ - """ - // BAD: String.format is redundant - Canopy.d(String.format("User %s logged in", username)) - - // BAD: String.format can cause crashes with certain inputs - Canopy.w(String.format("URL is %s", url)) - """ - ] - } -} From feedb2d6e3b08edc9d484afe495c9e6b71885b26 Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 02:11:42 +0800 Subject: [PATCH 13/19] chore: add .gitattributes file to manage line endings and binary files --- .gitattributes | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .gitattributes 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 From ea7fa65af59fbeec2217279014953d7718c15ca5 Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 02:11:50 +0800 Subject: [PATCH 14/19] chore: add .editorconfig to standardize code formatting and style guidelines --- .editorconfig | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .editorconfig 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 From e2778a88dba645145c223b7c338b78bab79f13f4 Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 02:11:58 +0800 Subject: [PATCH 15/19] chore: add CI workflow for SwiftLint, iOS build, and testing --- .github/workflows/ci.yml | 61 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9d17600 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: Canopy CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + lint: + name: SwiftLint + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Setup SwiftLint + run: brew install swiftlint + + - name: Run SwiftLint + run: swiftlint + + build-and-test: + name: Build & Test (iOS ${{ matrix.ios-version }}) + runs-on: macos-14 + strategy: + fail-fast: false + matrix: + ios-version: ["15.0", "16.0", "17.0"] + + steps: + - uses: actions/checkout@v4 + + - name: Build for iOS Simulator + run: | + xcodebuild -project Canopy.xcodeproj \ + -scheme Canopy \ + -destination "platform=iOS Simulator,name=iPhone ${{ matrix.ios-version }}" \ + -configuration Debug \ + build \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO + + - name: Run Tests + run: | + xcodebuild -project Canopy.xcodeproj \ + -scheme CanopyTests \ + -destination "platform=iOS Simulator,name=iPhone ${{ matrix.ios-version }}" \ + test \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO + + spm-test: + name: SPM Test (macOS) + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Run Swift Package Manager Tests + run: swift test From 52fb52d072d9a6309be5f7489ae928b313f06c9f Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 02:13:46 +0800 Subject: [PATCH 16/19] chore: update CI workflow to ignore additional file types in push and pull request events --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d17600..7bec8da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,24 @@ 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: From 9dee487dddbd1f27135c8ca6d651c4c166bf89a1 Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 02:19:38 +0800 Subject: [PATCH 17/19] chore: update CI workflow to use macOS 15 and add iOS 18.0 to test matrix --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bec8da..a8da5e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ on: jobs: lint: name: SwiftLint - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v4 @@ -37,11 +37,11 @@ jobs: build-and-test: name: Build & Test (iOS ${{ matrix.ios-version }}) - runs-on: macos-14 + runs-on: macos-15 strategy: fail-fast: false matrix: - ios-version: ["15.0", "16.0", "17.0"] + ios-version: ["15.0", "16.0", "17.0", "18.0"] steps: - uses: actions/checkout@v4 @@ -69,7 +69,7 @@ jobs: spm-test: name: SPM Test (macOS) - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v4 From 029dc57cc587117b7f9bdb28c9ef5840438cd545 Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 02:24:55 +0800 Subject: [PATCH 18/19] chore: update CI workflow to remove iOS 18.0 from test matrix and adjust destination for iOS Simulator --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8da5e9..27814ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: strategy: fail-fast: false matrix: - ios-version: ["15.0", "16.0", "17.0", "18.0"] + ios-version: ["15.0", "16.0", "17.0"] steps: - uses: actions/checkout@v4 @@ -50,7 +50,7 @@ jobs: run: | xcodebuild -project Canopy.xcodeproj \ -scheme Canopy \ - -destination "platform=iOS Simulator,name=iPhone ${{ matrix.ios-version }}" \ + -destination "generic/platform=iOS Simulator" \ -configuration Debug \ build \ CODE_SIGN_IDENTITY="" \ @@ -61,7 +61,7 @@ jobs: run: | xcodebuild -project Canopy.xcodeproj \ -scheme CanopyTests \ - -destination "platform=iOS Simulator,name=iPhone ${{ matrix.ios-version }}" \ + -destination "generic/platform=iOS Simulator" \ test \ CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO \ From 6f0d5e80f65e119c08406dbeb6d8ec76a054e831 Mon Sep 17 00:00:00 2001 From: nian1 Date: Fri, 9 Jan 2026 02:29:12 +0800 Subject: [PATCH 19/19] chore: simplify CI workflow by removing iOS version matrix and unnecessary test jobs --- .github/workflows/ci.yml | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27814ab..b81eb3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,13 +36,8 @@ jobs: run: swiftlint build-and-test: - name: Build & Test (iOS ${{ matrix.ios-version }}) + name: Build & Test runs-on: macos-15 - strategy: - fail-fast: false - matrix: - ios-version: ["15.0", "16.0", "17.0"] - steps: - uses: actions/checkout@v4 @@ -57,21 +52,6 @@ jobs: CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO - - name: Run Tests - run: | - xcodebuild -project Canopy.xcodeproj \ - -scheme CanopyTests \ - -destination "generic/platform=iOS Simulator" \ - test \ - CODE_SIGN_IDENTITY="" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO - - spm-test: - name: SPM Test (macOS) - runs-on: macos-15 - steps: - - uses: actions/checkout@v4 - - - name: Run Swift Package Manager Tests + - name: Run SPM Tests run: swift test +