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
+ }
+}