diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3bedf9a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + strategy: + matrix: + os: [macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + - name: Run tests + run: swift test --parallel diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a9163f --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +/.vscode diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/LogMacro.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/LogMacro.xcscheme new file mode 100644 index 0000000..e6416d4 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/LogMacro.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/LogMacroClient.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/LogMacroClient.xcscheme new file mode 100644 index 0000000..2e55b45 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/LogMacroClient.xcscheme @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fcb2165 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Toshiki Takezawa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..df43639 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "487e1e30174d4436cc5a82da699f1b72adb6f0c6ea350189369485c80bcd6dee", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", + "version" : "510.0.2" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..6c95aee --- /dev/null +++ b/Package.swift @@ -0,0 +1,55 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription +import CompilerPluginSupport + +let package = Package( + name: "LogMacro", + platforms: [ + .iOS(.v16), + .macOS(.v13), + .tvOS(.v16), + .watchOS(.v9), + .visionOS(.v1), + ], + products: [ + .library( + name: "LogMacro", + targets: ["LogMacro"] + ), + .executable( + name: "LogMacroClient", + targets: ["LogMacroClient"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-syntax.git", from: "510.0.0"), + ], + targets: [ + .executableTarget( + name: "LogMacroClient", + dependencies: ["LogMacro"] + ), + .macro( + name: "LogMacroImplementation", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ], + path: "./Sources/Implementation" + ), + .target( + name: "LogMacro", + dependencies: ["LogMacroImplementation"], + path: "./Sources/Interface" + ), + .testTarget( + name: "LogMacroTests", + dependencies: [ + "LogMacroImplementation", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..39a8df8 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# LogMacro +![Swift 5](https://img.shields.io/badge/swift-5-orange.svg) +![SPM compatible](https://img.shields.io/badge/SPM-Compatible-brightgreen.svg) +![MIT License](https://img.shields.io/badge/license-MIT-brightgreen.svg) + +A macro that outputs logs with formatting anywhere across modules. + +## Installation +### Swift Package Manager +```swift +.package(url: "https://github.com/to4iki/LogMacro", from: <#version#>) +``` + +## Usage +#### debug +```swift +#logDebug("debug") +``` + +#### warn +```swift +#logWarn("warn", category: .tracking) +``` + +#### fault +```swift +enum MyError: Error { + case unknown +} + +#logFault(MyError.unknown, category: .network) +``` + +#### Restrict logs to be posted per level +Set global `LogLevel` and configure which logs can be sent on a per-application basis. + +```swift +LogProcess.shared.setEnabledLogLevel(.warn) +``` + +#### Replacing log message +Register a `LogReplacingPlugin` for rewrite outgoing log messages. + +```swift +/// Replace the string "password" with an empty string. +struct PasswordLogReplacingPlugin: LogReplacingPlugin { + func newMessage(from message: String, level: LogLevel) -> String { + message.replacingOccurrences(of: "password", with: "") + } +} + +LogProcess.shared.setReplacingPlugin(PasswordLogReplacingPlugin()) +``` + +#### Processing after log post +Register a `LogPostActionPlugin` for perform any processing after the log is post. + +```swift +/// Print output only when messaga is `MyError`. +struct MyErrorLogPostActionPlugin: LogPostActionPlugin { + func execute(message: Any, level: LogLevel, file: String, function: String, line: Int) { + guard level >= .fault else { + return + } + guard message is MyError else { + return + } + print("MyErrorLogPostActionPlugin") + } +} + +LogProcess.shared.setPostActionPlugins(MyErrorLogPostActionPlugin()) +``` + +## License +LogMacro is released under the MIT license. diff --git a/Sources/Implementation/LogDebugMacro.swift b/Sources/Implementation/LogDebugMacro.swift new file mode 100644 index 0000000..ff56e30 --- /dev/null +++ b/Sources/Implementation/LogDebugMacro.swift @@ -0,0 +1,38 @@ +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// A macro that outputs `Logger.debug` with formatting anywhere across modules. +public struct LogDebugMacro: ExpressionMacro { + public static func expansion( + of node: some FreestandingMacroExpansionSyntax, + in context: some MacroExpansionContext + ) -> ExprSyntax { + guard let message = node.arguments.first?.expression else { + fatalError("Expected an argument") + } + + let categoryArg = node.arguments.first(where: { $0.label?.text == "category" }) + let loggerExpression: ExprSyntax + if let categoryExpr = categoryArg?.expression { + loggerExpression = "Log\(categoryExpr)" + } else { + loggerExpression = "Log.default" + } + + return """ + ({ + let level = LogLevel.debug + guard LogProcess.shared.canLogging(level: level) else { + return + } + + let _message = LogProcess.shared.replaceMessage(from: "\\(\(message))", level: level) + \(loggerExpression) + .debug("\\(LogMessage.make(_message, file: #file, function: #function, line: #line))") + + LogProcess.shared.executePostAction(message: \(message), level: level, file: #file, function: #function, line: #line) + })() + """ + } +} diff --git a/Sources/Implementation/LogFaultMacro.swift b/Sources/Implementation/LogFaultMacro.swift new file mode 100644 index 0000000..756b0aa --- /dev/null +++ b/Sources/Implementation/LogFaultMacro.swift @@ -0,0 +1,38 @@ +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// A macro that outputs `Logger.fault` with formatting anywhere across modules. +public struct LogFaultMacro: ExpressionMacro { + public static func expansion( + of node: some FreestandingMacroExpansionSyntax, + in context: some MacroExpansionContext + ) -> ExprSyntax { + guard let message = node.arguments.first?.expression else { + fatalError("Expected a message argument") + } + + let categoryArg = node.arguments.first(where: { $0.label?.text == "category" }) + let loggerExpression: ExprSyntax + if let categoryExpr = categoryArg?.expression { + loggerExpression = "Log\(categoryExpr)" + } else { + loggerExpression = "Log.default" + } + + return """ + ({ + let level = LogLevel.fault + guard LogProcess.shared.canLogging(level: level) else { + return + } + + let _message = LogProcess.shared.replaceMessage(from: "\\(\(message))", level: level) + \(loggerExpression) + .fault("\\(LogMessage.make(_message, file: #file, function: #function, line: #line))") + + LogProcess.shared.executePostAction(message: \(message), level: level, file: #file, function: #function, line: #line) + })() + """ + } +} diff --git a/Sources/Implementation/LogMacroPlugin.swift b/Sources/Implementation/LogMacroPlugin.swift new file mode 100644 index 0000000..062f374 --- /dev/null +++ b/Sources/Implementation/LogMacroPlugin.swift @@ -0,0 +1,11 @@ +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct LogMacroPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + LogDebugMacro.self, + LogWarnMacro.self, + LogFaultMacro.self + ] +} diff --git a/Sources/Implementation/LogWarnMacro.swift b/Sources/Implementation/LogWarnMacro.swift new file mode 100644 index 0000000..a1c8bb1 --- /dev/null +++ b/Sources/Implementation/LogWarnMacro.swift @@ -0,0 +1,38 @@ +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// A macro that outputs `Logger.warning` with formatting anywhere across modules. +public struct LogWarnMacro: ExpressionMacro { + public static func expansion( + of node: some FreestandingMacroExpansionSyntax, + in context: some MacroExpansionContext + ) -> ExprSyntax { + guard let message = node.arguments.first?.expression else { + fatalError("Expected a message argument") + } + + let categoryArg = node.arguments.first(where: { $0.label?.text == "category" }) + let loggerExpression: ExprSyntax + if let categoryExpr = categoryArg?.expression { + loggerExpression = "Log\(categoryExpr)" + } else { + loggerExpression = "Log.default" + } + + return """ + ({ + let level = LogLevel.warn + guard LogProcess.shared.canLogging(level: level) else { + return + } + + let _message = LogProcess.shared.replaceMessage(from: "\\(\(message))", level: level) + \(loggerExpression) + .warning("\\(LogMessage.make(_message, file: #file, function: #function, line: #line))") + + LogProcess.shared.executePostAction(message: \(message), level: level, file: #file, function: #function, line: #line) + })() + """ + } +} diff --git a/Sources/Interface/Log.swift b/Sources/Interface/Log.swift new file mode 100644 index 0000000..7c2f81f --- /dev/null +++ b/Sources/Interface/Log.swift @@ -0,0 +1,34 @@ +import Foundation +import OSLog + +private let subsystem = Bundle.main.bundleIdentifier ?? "" + +public enum Log { + public static let `default` = Logger(subsystem: subsystem, category: LogCategory.default.rawValue) + public static let tracking = Logger(subsystem: subsystem, category: LogCategory.tracking.rawValue) + public static let network = Logger(subsystem: subsystem, category: LogCategory.network.rawValue) +} + +public enum LogCategory: String { + case `default` + case tracking + case network +} + +public enum LogLevel: Int, Comparable { + case debug = 0 + case warn = 1 + case fault = 2 + + public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +public enum LogMessage { + /// [File.swift:112] function > "message" + public static func make(_ message: String, file: String = #file, function: String = #function, line: Int = #line) -> String { + let simpleFileName = file.components(separatedBy: "/").last ?? file + return "[\(simpleFileName):\(line)] \(function) > " + message + } +} diff --git a/Sources/Interface/LogMacro.swift b/Sources/Interface/LogMacro.swift new file mode 100644 index 0000000..7bfc2a2 --- /dev/null +++ b/Sources/Interface/LogMacro.swift @@ -0,0 +1,20 @@ +/// A macro that outputs `Logger.debug` with formatting anywhere across modules. +@freestanding(expression) +public macro logDebug( + _ message: Any, + category: LogCategory = .default +) = #externalMacro(module: "LogMacroImplementation", type: "LogDebugMacro") + +/// A macro that outputs `Logger.warning` with formatting anywhere across modules. +@freestanding(expression) +public macro logWarn( + _ message: Any, + category: LogCategory = .default +) = #externalMacro(module: "LogMacroImplementation", type: "LogWarnMacro") + +/// A macro that outputs `Logger.fault` with formatting anywhere across modules. +@freestanding(expression) +public macro logFault( + _ message: Any, + category: LogCategory = .default +) = #externalMacro(module: "LogMacroImplementation", type: "LogFaultMacro") diff --git a/Sources/Interface/LogProcess.swift b/Sources/Interface/LogProcess.swift new file mode 100644 index 0000000..9fca586 --- /dev/null +++ b/Sources/Interface/LogProcess.swift @@ -0,0 +1,50 @@ +public protocol LogReplacingPlugin { + func newMessage(from message: String, level: LogLevel) -> String +} + +public protocol LogPostActionPlugin { + func execute(message: Any, level: LogLevel, file: String, function: String, line: Int) +} + +public final class LogProcess { + public static let shared = LogProcess() + + private var enabledLogLevel: LogLevel = .debug + private var replacingPlugin: LogReplacingPlugin? + private var postActionPlugins: [LogPostActionPlugin] = [] + + public func setEnabledLogLevel(_ level: LogLevel) { + self.enabledLogLevel = level + } + + public func setReplacingPlugin(_ plugin: LogReplacingPlugin) { + self.replacingPlugin = plugin + } + + public func setPostActionPlugins(_ plugins: LogPostActionPlugin...) { + self.postActionPlugins = plugins + } + + public func canLogging(level: LogLevel) -> Bool { + level >= enabledLogLevel + } + + public func replaceMessage(from message: String, level: LogLevel) -> String { + guard let replacingPlugin else { + return message + } + return replacingPlugin.newMessage(from: message, level: level) + } + + public func executePostAction(message: Any, level: LogLevel, file: String, function: String, line: Int) { + Task { + await withTaskGroup(of: Void.self) { group in + for plugin in postActionPlugins { + group.addTask { + plugin.execute(message: message, level: level, file: file, function: function, line: line) + } + } + } + } + } +} diff --git a/Sources/LogMacroClient/main.swift b/Sources/LogMacroClient/main.swift new file mode 100644 index 0000000..60e76e1 --- /dev/null +++ b/Sources/LogMacroClient/main.swift @@ -0,0 +1,46 @@ +import LogMacro + +struct PasswordLogReplacingPlugin: LogReplacingPlugin { + func newMessage(from message: String, level: LogLevel) -> String { + message.replacingOccurrences(of: "password", with: "") + } +} + +struct MyErrorLogPostActionPlugin: LogPostActionPlugin { + func execute(message: Any, level: LogLevel, file: String, function: String, line: Int) { + guard level >= .fault else { + return + } + guard message is MyError else { + return + } + print("MyErrorLogPostActionPlugin") + } +} + +enum MyError: Error { + case unknown +} + +func func1() { + #logDebug("debug") +} + +func func2() { + #logWarn("warn", category: .tracking) +} + +func func3() { + #logFault(MyError.unknown, category: .network) +} + +// MARK: - execute + +LogProcess.shared.setReplacingPlugin(PasswordLogReplacingPlugin()) +LogProcess.shared.setPostActionPlugins(MyErrorLogPostActionPlugin()) + +func1() +func2() +_ = try await Task.sleep(nanoseconds: UInt64(1 * 1_000_000_000)) +func3() +_ = try await Task.sleep(nanoseconds: UInt64(1 * 1_000_000_000)) diff --git a/Tests/LogMacroTests/LogMacroTests.swift b/Tests/LogMacroTests/LogMacroTests.swift new file mode 100644 index 0000000..826b3c1 --- /dev/null +++ b/Tests/LogMacroTests/LogMacroTests.swift @@ -0,0 +1,104 @@ +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +#if canImport(LogMacroImplementation) + import LogMacroImplementation + + let testMacros: [String: Macro.Type] = [ + "logDebug": LogDebugMacro.self, + "logWarn": LogWarnMacro.self, + "logFault": LogFaultMacro.self + ] +#endif + +final class LogMacroTests: XCTestCase { + func testLogDebug() throws { + #if canImport(LogMacroImplementation) + assertMacroExpansion( + """ + #logDebug("message") + """, + expandedSource: + """ + ({ + let level = LogLevel.debug + guard LogProcess.shared.canLogging(level: level) else { + return + } + + let _message = LogProcess.shared.replaceMessage(from: "\\("message")", level: level) + Log.default + .debug("\\(LogMessage.make(_message, file: #file, function: #function, line: #line))") + + LogProcess.shared.executePostAction(message: "message", level: level, file: #file, function: #function, line: #line) + })() + """, + macros: testMacros + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif + } + + func testLogWarn() throws { + #if canImport(LogMacroImplementation) + assertMacroExpansion( + """ + #logWarn("message", category: .tracking) + """, + expandedSource: + """ + ({ + let level = LogLevel.warn + guard LogProcess.shared.canLogging(level: level) else { + return + } + + let _message = LogProcess.shared.replaceMessage(from: "\\("message")", level: level) + Log.tracking + .warning("\\(LogMessage.make(_message, file: #file, function: #function, line: #line))") + + LogProcess.shared.executePostAction(message: "message", level: level, file: #file, function: #function, line: #line) + })() + """, + macros: testMacros + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif + } + + func testLogFault() throws { + #if canImport(LogMacroImplementation) + enum MyError: Error { + case unknown + } + assertMacroExpansion( + """ + #logFault(MyError.unknown, category: .network) + """, + expandedSource: + """ + ({ + let level = LogLevel.fault + guard LogProcess.shared.canLogging(level: level) else { + return + } + + let _message = LogProcess.shared.replaceMessage(from: "\\(MyError.unknown)", level: level) + Log.network + .fault("\\(LogMessage.make(_message, file: #file, function: #function, line: #line))") + + LogProcess.shared.executePostAction(message: MyError.unknown, level: level, file: #file, function: #function, line: #line) + })() + """, + macros: testMacros + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif + } +}