#### libremiliacr
#### Copyright(C) 2020-2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or modify
#### it under the terms of the GNU General Public License as published
#### the Free Software Foundation, either version 3 of the License, or
#### (at your option) any later version.
####
#### This program is distributed in the hope that it will be useful,
#### but WITHOUT ANY WARRANTY; without even the implied warranty of
#### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
#### GNU General Public License for more details.
####
#### You should have received a copy of the GNU General Public License
#### along with this program.If not, see<http:####www.gnu.org/licenses/.>
require "colorize"
module RemiLib
@@log : Logger? = nil
# An instance of a `Logger`, provided for convenience. You may use your own
# instance if you wish, or this one.
def self.log : Logger
@@log = Logger.new if @@log.nil?
@@log.not_nil!
end
def self.log=(newLogger : Logger) : Nil
@@log = newLogger
end
# The base `Logger` class provides basic logging facilities.
#
# The format of each message is:
# ```
# [Header]: your message
# ```
class Logger
ANSI_REGEX = Regex.new(%{\u{1b}\\[[0-9;]*m})
# The debugging level. See `#dlog`.
property debugLevel : UInt8 = 0u8
# The verbosity level. See `#vlog`.
property verbosityLevel : UInt8 = 0u8
# The stream that `#log`/`#vlog`/`#<<` messages are printed to.
property defaultStream : IO = STDOUT.as(IO)
# The stream that debug messages are printed to
property debugStream : IO = STDERR.as(IO)
# The stream that warning messages are printed to
property warnStream : IO = STDERR.as(IO)
# The stream that error messages are printed to
property errorStream : IO = STDERR.as(IO)
# When `true`, all logging calls will ensure that a newline is
# printed after their message is printed.
property? ensureNewline : Bool = true
# The default log header.
property defaultHeader : String = ""
# The verbose log header.
property verboseHeader : String = "Note"
# The header printed before each debug log message.
property debugHeader : String = "Debug"
# The header printed before each warrning log message.
property warnHeader : String = "Warning"
# The header printed before each error log message.
property errorHeader : String = "Error"
# The header printed before a fatal log message.
property fatalHeader : String = "Fatal"
# When true, color output is completely disabled.
property? noColors : Bool = false
# The color of the default log header
property defaultColor : Symbol = :default
# The color of the verbose log header
property verboseColor : Symbol = :cyan
# The color of the debug log header
property debugColor : Symbol = :green
# The color of the warning log header
property warnColor : Symbol = :yellow
# The color of the error log header
property errorColor : Symbol = :red
# When not `String#empty?`, use this as a `Time::Format` string to
# append a timestamp to each header. If this is empty, no
# timestamp is shown.
#
# Note that if the header is also an empty string, no timestamp will be
# shown for that message.
property timestamp : String = ""
# Additional streams to log messages to.
property otherStreams : Array(IO) = [] of IO
# When `true`, headers are shown for all of the logging messages. However,
# note that `#log`/`#<<` have additional constraints. See them for more
# details.
property? showHeaders : Bool = true
# When true, `IO#flush` will be called after every log message. This
# applies to all streams associated with the instance.
property? forceFlush : Bool = false
# When `true`, then all output will have meta characters (`ESC`, `SUB`,
# `BEL`, etc.) stripped from the output and replaced with textual
# representations.
#
# See `::RemiLib.sanitize` for more info.
property? sanitizeOutput : Bool = true
def initialize(*, ensureNewline : Bool = true, defaultHeader = "", verboseHeader : String = "Note",
debugHeader : String = "Debug", warnHeader : String = "Warning",
errorHeader : String = "Error", fatalHeader : String = "Fatal", showHeaders : Bool = true)
@ensureNewline = ensureNewline
@defaultHeader = defaultHeader
@verboseHeader = verboseHeader
@debugHeader = debugHeader
@warnHeader = warnHeader
@errorHeader = errorHeader
@fatalHeader = fatalHeader
@showHeaders = showHeaders
end
# Prints a header if `#showHeaders` is true.
protected def printHeader(stream : IO, color : Symbol, header : String)
if @showHeaders
if @noColors
stream << "["
if @sanitizeOutput
stream << RemiLib.sanitize(header, true)
else
stream << header
end
unless timestamp.empty?
stream << " - #{Time.local.to_s(@timestamp)}"
end
stream << "]: "
else
Colorize.with.colorize(color).surround(stream) do
stream << "["
if @sanitizeOutput
stream << RemiLib.sanitize(header, true)
else
stream << header
end
unless timestamp.empty?
stream << " - #{Time.local.to_s(@timestamp)}"
end
stream << "]: "
end
end
end
end
@[AlwaysInline]
protected def prepareMessage(color : Symbol, header : String, msg : String) : String
String.build do |str|
hasNewline : Bool = msg.ends_with?('\n')
printHeader(str, color, header) unless header.empty?
if @sanitizeOutput
if hasNewline
str << RemiLib.sanitize(msg[..-2], true) << '\n'
else
str << RemiLib.sanitize(msg, true)
end
else
str << msg
end
if @ensureNewline && !hasNewline
str << '\n'
end
end
end
protected def printMessage(stream : IO, color : Symbol, header : String, msg : String) : Nil
outStr = prepareMessage(color, header, msg)
# Write to main stream
stream << outStr
stream.flush if @forceFlush
# Write to other streams, if there are any
unless otherStreams.empty?
outStr = outStr.gsub(ANSI_REGEX, "")
otherStreams.each do |other|
other << outStr
other.flush if @forceFlush
end
end
end
protected def printException(stream : IO, color : Symbol, header : String, err : Exception) : Nil
outStr = String.build do |str|
if err.message.nil? || err.message == ""
str << "(Exception has no message)"
else
str << "#{err}"
end
str << '\n'
err.backtrace.each do |line|
str << line << '\n'
end
end
outStr = prepareMessage(color, header, outStr)
stream << outStr
stream.flush if @forceFlush
# Write to other streams, if there are any
unless otherStreams.empty?
outStr = outStr.gsub(ANSI_REGEX, "")
otherStreams.each do |other|
other << outStr
other.flush if @forceFlush
end
end
end
# Prints a message to `#defaultStream`. If `#defaultHeader` is not an empty
# string, then this will first print that header using `#defaultColor`.
def log(msg : String)
printMessage(defaultStream, defaultColor, defaultHeader, msg)
end
# Prints a message to `#defaultStream` by calling `block`, which must return
# a string. If `#defaultHeader` is not an empty string, then this will
# first print that header using `#defaultColor`.
def log(&block : Proc(String))
log(block.call)
end
# :ditto:
def <<(text)
log(text)
end
# Prints a message to `#defaultStream` if `#verbosityLevel` is greater than
# or equal to `minLevel`. If `#verboseHeader` is not an empty string, then
# this will first print that header using `#defaultColor`.
#
# If `#verbosityLevel` is less than `minLevel`, this does nothing.
#
# Returns `true` if it printed something, or `false` otherwise.
def vlog(minLevel : UInt8, msg : String) : Bool
if verbosityLevel >= minLevel
printMessage(defaultStream, verboseColor, verboseHeader, msg)
return true
end
false
end
# Prints a message to `#defaultStream` if `#verbosityLevel` is greater than
# or equal to `minLevel` by calling `block`, which must return a string. If
# `#verboseHeader` is not an empty string, then this will first print that
# header using `#defaultColor`.
#
# If `#verbosityLevel` is less than `minLevel`, this does nothing.
#
# Returns `true` if it printed something, or `false` otherwise.
def vlog(minLevel : UInt8, &block : Proc(String)) : Bool
vlog(minLevel, block.call)
end
# Convenience function that just calls `vlog` with a minimum level of 1.
def vlog(msg : String) : Bool
vlog(1u8, msg)
end
# Convenience function that just calls `vlog` with a minimum level of 1.
def vlog(&block : Proc(String)) : Bool
vlog(1u8) { block.call }
end
# Prints a message to `#debugStream` if `#debugLevel` is greater than or
# equal to `minLevel`. This always prints a header using `#debugHeader` and
# `#debugColor`.
#
# If `#debugLevel` is less than `minLevel`, this does nothing.
#
# Returns `true` if a message was printed, or `false` otherwise.
def dlog(minLevel : UInt8, msg : String) ; Bool
if debugLevel >= minLevel
printMessage(debugStream, debugColor, debugHeader, msg)
return true
end
false
end
# Prints a message to `#debugStream` if `#debugLevel` is greater than or
# equal to `minLevel` by calling `block`, which must return a string. This
# always prints a header using `#debugHeader` and `#debugColor`.
#
# If `#debugLevel` is less than `minLevel`, this does nothing.
#
# Returns `true` if a message was printed, or `false` otherwise.
def dlog(minLevel : UInt8, &block : Proc(String)) : Bool
dlog(minLevel, block.call)
end
# Prints a message to `#debugStream` if `#debugLevel` is greater than or
# equal to 1. This always prints a header using `#debugHeader` and
# `#debugColor`.
#
# Returns `true` if a message was printed, or `false` otherwise.
def dlog(msg : String) : Bool
dlog(1u8, msg)
end
# Prints a message to `#debugStream` if `#debugLevel` is greater than or
# equal to 1 by calling `block`, which must return a string. This always
# prints a header using `#debugHeader` and `#debugColor`.
#
# Returns `true` if a message was printed, or `false` otherwise.
def dlog(&block : Proc(String)) : Bool
dlog(1u8, block.call)
end
# Prints a message to `#debugStream` regardless of `#debugLevel`. This
# always prints a header using `#debugHeader` and `#debugColor`.
def dlog!(msg : String) : Bool
dlog(0, msg)
true
end
# Prints a message to `#debugStream` regardless of `#debugLevel`. This
# always prints a header using `#debugHeader` and `#debugColor`.
def dlog!(&block : Proc(String)) : Bool
dlog(0) { block.call }
true
end
# Prints a message to `#debugStream` only if `#debugLevel` is equal to 255.
# This always prints a header using `#debugHeader` and `#debugColor`.
def trace(msg : String) : Bool
dlog(255u8, msg)
end
# Prints a message to `#debugStream` by calling `block` (which must return a
# string), but only if `#debugLevel` is equal to 255. This always prints a
# header using `#debugHeader` and `#debugColor`.
def trace(&block : Proc(String)) : Bool
dlog(255u8) { block.call }
end
# Prints a message to `#warnStream`. This always prints a header using
# `#warnHeader` and `#warnColor`.
def warn(msg : String)
printMessage(warnStream, warnColor, warnHeader, msg)
end
# Prints a message to `#warnStream` by calling `block`, which must return a
# string. This always prints a header using `#warnHeader` and `#warnColor`.
def warn(&block : Proc(String))
self.warn(block.call)
end
# Prints an exception and its backtrace to `#warnStream`. This always
# prints a header using `#warnHeader` and `#warnColor`.
def warn(err : Exception)
printException(warnStream, warnColor, warnheader, err)
end
# Prints a message to `#errorStream`. This always prints a header using
# `#errorHeader` and `#errorColor`.
def error(msg : String)
printMessage(errorStream, errorColor, errorHeader, msg)
end
# Prints a message to `#errorStream` by calling `block`, which must return a
# string. This always prints a header using `#errorHeader` and
# `#errorColor`.
def error(&block : Proc(String))
error(block.call)
end
# Prints an exception and its backtrace to `#errorStream`. This always
# prints a header using `#errorHeader` and `#errorColor`.
def error(err : Exception)
printException(errorStream, errorColor, errorHeader, err)
end
# Prints an error message to `#errorStream`, then calls `exit` with
# `exitCode`. This always uses the header "FATAL".
def fatal(msg : String, exitCode = 1) : NoReturn
printMessage(errorStream, errorColor, fatalHeader, msg)
exit(exitCode)
end
# Prints an error message to `#errorStream` by calling `block` (which must
# return a string), then calls `exit` with an exit code of 1. This always
# uses the header "FATAL".
def fatal(&block : Proc(String)) : NoReturn
fatal(block.call)
end
# Prints an error message to `#errorStream` by calling `block`. The `block`
# must return a tuple consisting of a string (the error message) and an
# `Int32` (the exit code). This then calls `exit` with the exit code
# returned by `block. This always uses the header "FATAL".
def fatal(&block : Proc(Tuple(String, Int32))) : NoReturn
msg, code = block.call
fatal(msg, code)
end
# Prints an exception and its backtrace to `#errorStream`, then calls `exit`
# with `exitCode`. This always uses the header "FATAL".
def fatal(err : Exception, exitCode = 1) : NoReturn
printException(errorStream, errorColor, fatalHeader, err)
exit(exitCode)
end
end
# A `ConcurrentLogger` is a fiber-safe and thread-safe `Logger` that can
# receive messages from multiple fibers.
class ConcurrentLogger < Logger
# The default backlog size.
DEFAULT_CHANNEL_SIZE = 2048
# :nodoc:
STOPPED = 0u8
# :nodoc:
RUNNING = 1u8
# :nodoc:
STOPPING = 2u8
private struct StopFiberMsg
def initialize
end
end
private record StringMsg, stream : IO, color : Symbol, header : String, msg : String
private record ExceptionMsg, stream : IO, color : Symbol, header : String, msg : Exception
# :nodoc:
alias ChanMsg = StringMsg|ExceptionMsg|StopFiberMsg
@chan : Channel(ChanMsg) = Channel(ChanMsg).new(DEFAULT_CHANNEL_SIZE)
@chanStatus : Atomic(UInt8) = Atomic(UInt8).new(STOPPED)
@str : IO::Memory = IO::Memory.new
# Creates a new `ConcurrentLogger` instance.
def initialize(*, ensureNewline : Bool = true, defaultHeader = "", verboseHeader : String = "Note",
debugHeader : String = "Debug", warnHeader : String = "Warning",
errorHeader : String = "Error", fatalHeader : String = "Fatal", showHeaders : Bool = true)
@ensureNewline = ensureNewline
@defaultHeader = defaultHeader
@verboseHeader = verboseHeader
@debugHeader = debugHeader
@warnHeader = warnHeader
@errorHeader = errorHeader
@fatalHeader = fatalHeader
@showHeaders = showHeaders
start
end
# # Creates a new `ConcurrentLogger` instance with a backlog of *chanSize*.
#
# You may want to adjust the chanSize if you experience deadlocks due to an
# extreme rush of messages right after logger startup, or if you have
# messages appear before the logger is started.
def initialize(chanSize : Int, *, ensureNewline : Bool = true, defaultHeader = "", verboseHeader : String = "Note",
debugHeader : String = "Debug", warnHeader : String = "Warning",
errorHeader : String = "Error", fatalHeader : String = "Fatal", showHeaders : Bool = true)
@ensureNewline = ensureNewline
@defaultHeader = defaultHeader
@verboseHeader = verboseHeader
@debugHeader = debugHeader
@warnHeader = warnHeader
@errorHeader = errorHeader
@fatalHeader = fatalHeader
@showHeaders = showHeaders
@chan = Channel(ChanMsg).new(chanSize)
start
end
# Starts the logger and waits for the fiber to begin working.
protected def start : self
readyChan = Channel(Bool).new(1)
spawn do
@chanStatus.set(RUNNING)
readyChan.send(true)
while @chanStatus.get == RUNNING
select
when msg = @chan.receive
case msg
in StringMsg then printMessage(msg.stream, msg.color, msg.header, msg.msg)
in ExceptionMsg then printException(msg.stream, msg.color, msg.header, msg.msg)
in StopFiberMsg then @chanStatus.set(STOPPING)
end
end
Fiber.yield
end
@chanStatus.set(STOPPED)
end
readyChan.receive
self
end
# Stops the logger.
protected def stop : self
@chanStatus.set(STOPPED)
Fiber.yield
self
end
# Prints all pending messages.
def drain : self
wasStarted = @chanStatus.get == RUNNING
stop
loop do
Fiber.yield
select
when msg = @chan.receive
case msg
in StringMsg then printMessage(msg.stream, msg.color, msg.header, msg.msg)
in ExceptionMsg then printException(msg.stream, msg.color, msg.header, msg.msg)
in StopFiberMsg then @chanStatus.set(STOPPING)
end
else break
end
end
start if wasStarted
Fiber.yield
self
end
protected def submitMessage(stream : IO, color : Symbol, header : String, msg : String)
#raise "Logger not yet started" if @chanStatus.get != RUNNING
@chan.send(StringMsg.new(stream, color, header, msg))
end
protected def submitException(stream : IO, color : Symbol, header : String, err : Exception)
#raise "Logger not yet started" if @chanStatus.get != RUNNING
outStr = String.build do |str|
printHeader(str, color, header)
if err.message.nil? || err.message == ""
str << "(Exception has no message)"
else
str << "#{err}"
end
str << '\n'
err.backtrace.each do |line|
str << line << '\n'
end
end
@chan.send(StringMsg.new(stream, color, header, outStr))
end
# :inherit:
def log(msg : String)
submitMessage(defaultStream, defaultColor, defaultHeader, msg)
end
# :inherit:
def vlog(minLevel : UInt8, msg : String) : Bool
if verbosityLevel >= minLevel
submitMessage(defaultStream, verboseColor, verboseHeader, msg)
return true
end
false
end
# :inherit:
def dlog(minLevel : UInt8, msg : String) ; Bool
if debugLevel >= minLevel
submitMessage(debugStream, debugColor, debugHeader, msg)
return true
end
false
end
# :inherit:
def warn(msg : String)
submitMessage(warnStream, warnColor, warnHeader, msg)
end
# Prints an exception and its backtrace to `#warnStream`. This always
# prints a header using `#warnHeader` and `#warnColor`.
def warn(err : Exception)
submitException(warnStream, warnColor, warnheader, err)
end
# :inherit:
def error(msg : String)
submitMessage(errorStream, errorColor, errorHeader, msg)
end
# :inherit:
def error(err : Exception)
submitException(errorStream, errorColor, errorHeader, err)
end
# Prints an error message to `#errorStream`, then calls `exit` with
# `exitCode`. This always uses the header "FATAL".
#
# This will override all queued messages.
def fatal(msg : String, exitCode = 1) : NoReturn
@chanStatus.set(STOPPED)
printMessage(errorStream, errorColor, fatalHeader, msg)
exit(exitCode)
end
# Prints an error message to `#errorStream` by calling `block` (which must
# return a string), then calls `exit` with an exit code of 1. This always
# uses the header "FATAL".
#
# This will override all queued messages.
def fatal(&block : Proc(String)) : NoReturn
fatal(block.call)
end
# Prints an error message to `#errorStream` by calling `block`. The `block`
# must return a tuple consisting of a string (the error message) and an
# `Int32` (the exit code). This then calls `exit` with the exit code
# returned by `block. This always uses the header "FATAL".
#
# This will override all queued messages.
def fatal(&block : Proc(Tuple(String, Int32))) : NoReturn
msg, code = block.call
fatal(msg, code)
end
# Prints an exception and its backtrace to `#errorStream`, then calls `exit`
# with `exitCode`. This always uses the header "FATAL".
#
# This will override all queued messages.
def fatal(err : Exception, exitCode = 1) : NoReturn
@chanStatus.set(STOPPED)
printException(errorStream, errorColor, fatalHeader, err)
exit(exitCode)
end
end
end