Login
Artifact [9cd7493149]
Login

Artifact 9cd7493149414df94179aa9e4d78b0230d30b72aa2d329dd0e9be2d98fe89b70:


#### 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