lib/async/logger.rb



# Copyright, 2017, by Samuel G. D. Williams. <http://www.codeotaku.com>
# 
# 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.

require_relative 'terminal'

# Downstream gems often use `Logger:::LEVEL` constants, so we pull this in so they are available. That being said, the code should be fixed.
require 'logger'

module Async
	class Logger
		class Buffer < StringIO
			def initialize(prefix = nil)
				@prefix = prefix
				
				super()
			end
			
			def puts(*args, prefix: @prefix)
				args.each do |arg|
					self.write(prefix) if prefix
					
					super(arg)
				end
			end
		end
		
		LEVELS = {debug: 0, info: 1, warn: 2, error: 3, fatal: 4}
		
		LEVELS.each do |name, level|
			const_set(name.to_s.upcase, level)
			
			define_method(name) do |subject = nil, *arguments, &block|
				enabled = @subjects[subject.class]
				
				if enabled == true or (enabled != false and level >= @level)
					self.format(subject, *arguments, &block)
				end
			end
			
			define_method("#{name}!") do
				@level = level
			end
		end
		
		def initialize(output, level: 1)
			@output = output
			@level = level
			@start = Time.now
			
			@terminal = Terminal.new(output)
			@reset_style = @terminal.reset
			@prefix_style = @terminal.color(Terminal::Colors::CYAN)
			@subject_style = @terminal.color(nil, nil, Terminal::Attributes::BOLD)
			@exception_title_style = @terminal.color(Terminal::Colors::RED, nil, Terminal::Attributes::BOLD)
			@exception_line_style = @terminal.color(Terminal::Colors::RED)
			
			@subjects = {}
		end
		
		attr :level
		
		def level= value
			if value.is_a? Symbol
				@level = LEVELS[value]
			else
				@level = value
			end
		end
		
		def enabled?(subject)
			@subjects[subject.class] == true
		end
		
		def enable(subject)
			@subjects[subject.class] = true
		end
		
		def disable(subject)
			@subjects[subject.class] = false
		end
		
		def log(level, *arguments, &block)
			unless level.is_a? Symbol
				level = LEVELS[level]
			end
			
			self.send(level, *arguments, &block)
		end
		
		def format(subject = nil, *arguments, &block)
			prefix = time_offset_prefix
			indent = " " * prefix.size
			
			buffer = Buffer.new("#{indent}| ")
			
			if subject
				format_subject(prefix, subject, output: buffer)
			end
			
			arguments.each do |argument|
				format_argument(argument, output: buffer)
			end
			
			if block_given?
				if block.arity.zero?
					format_argument(yield, output: buffer)
				else
					yield(buffer, @terminal)
				end
			end
			
			@output.write buffer.string
		end
		
		def format_argument(argument, output: @output)
			if argument.is_a? Exception
				format_exception(argument, output: output)
			else
				format_value(argument, output: output)
			end
		end
		
		def format_exception(exception, prefix = nil, pwd: Dir.pwd, output: @output)
			output.puts " #{prefix}#{@exception_title_style}#{exception.class}#{@reset_style}: #{exception}"
			
			exception.backtrace.each_with_index do |line, index|
				path, offset, message = line.split(":")
				
				# Make the path a bit more readable
				path.gsub!(/^#{pwd}\//, "./")
				
				output.puts " #{index == 0 ? "→" : " "} #{@exception_line_style}#{path}:#{offset}#{@reset_style} #{message}"
			end
			
			if exception.cause
				format_exception(exception.cause, "Caused by ", pwd: pwd, output: output)
			end
		end
		
		def format_subject(prefix, subject, output: @output)
			output.puts "#{@subject_style}#{subject}#{@reset_style}", prefix: "#{@prefix_style}#{prefix}: "
		end
		
		def format_value(value, output: @output)
			string = value.to_s
			
			string.each_line do |line|
				output.puts "#{line}"
			end
		end
		
		def time_offset_prefix
			offset = Time.now - @start
			minutes = (offset/60).floor
			seconds = (offset - (minutes*60))
			
			if minutes > 0
				"#{minutes}m#{seconds.floor}s"
			else
				"#{seconds.round(2)}s"
			end.rjust(6)
		end
	end
	
	# The Async Logger class.
	class << self
		# @attr logger [Logger] the global logger instance used by `Async`.
		attr :logger
	
		# Set the default log level based on `$DEBUG` and `$VERBOSE`.
		def default_log_level
			if $DEBUG
				Logger::DEBUG
			elsif $VERBOSE
				Logger::INFO
			else
				Logger::WARN
			end
		end
	end

	# Create the logger instance.
	@logger = Logger.new($stderr, level: self.default_log_level)
end