Browse Source

command_detection

main
pvincent 2 months ago
parent
commit
b1ba5272b8
  1. 2
      Procfile.dev
  2. 5
      config/application.rb
  3. 7
      config/command_detection.rb
  4. 11
      config/environments/development.rb
  5. 2
      config/initializers/monkey_patcher.rb
  6. 58
      lib/formatters/ansi_colors.rb
  7. 20
      lib/formatters/ansi_dimensions.rb
  8. 245
      lib/formatters/ansi_formatter.rb
  9. 80
      lib/formatters/ansi_wrapper.rb
  10. 82
      lib/formatters/basic_formatter.rb
  11. 60
      lib/semantic/ansi_colors.rb
  12. 22
      lib/semantic/ansi_dimensions.rb
  13. 247
      lib/semantic/ansi_formatter.rb
  14. 82
      lib/semantic/ansi_wrapper.rb
  15. 84
      lib/semantic/basic_formatter.rb

2
Procfile.dev

@ -1,3 +1,3 @@
web: RUBY_DEBUG_OPEN=true bundle exec -- rails server --port "${RAILS_PORT:-7500}" web: RUBY_DEBUG_OPEN=true bundle exec -- rails server --port "${RAILS_PORT:-7500}"
css: BROWSERSLIST_IGNORE_OLD_DATA=true bundle exec -- rails tailwindcss:watch
css: bundle exec -- rails tailwindcss:watch

5
config/application.rb

@ -1,4 +1,5 @@
require_relative 'boot' require_relative 'boot'
require_relative 'command_detection'
require 'rails/all' require 'rails/all'
# Require the gems listed in Gemfile, including any gems # Require the gems listed in Gemfile, including any gems
@ -9,8 +10,10 @@ Bundler.require(*Rails.groups)
module EasyGoingRails module EasyGoingRails
# Main Application # Main Application
class Application < Rails::Application class Application < Rails::Application
include CommandDetection
# Initialize configuration defaults for originally generated Rails version. # Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.1
config.load_defaults 7.2
# Please, add to the `ignore` list any other `lib` subdirectories that do # Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded. # not contain `.rb` files, or that should not be reloaded or eager loaded.

7
config/command_detection.rb

@ -0,0 +1,7 @@
# useful methods for figuring out which kind of process is running
module CommandDetection
def server? = Rails.const_defined?('Server')
def console? = !server? && defined?(Rails::Console) == 'constant'
def rake? = !server? && !console? && Rails.const_defined?('Rake')
def tailwind_watcher? = rake? && Rake.application.top_level_tasks.first == 'tailwindcss:watch'
end

11
config/environments/development.rb

@ -77,9 +77,10 @@ Rails.application.configure do # rubocop:disable Metrics/BlockLength
routes.default_url_options[:port] = ARGV[1] # ie: Procfile.dev --port PORT routes.default_url_options[:port] = ARGV[1] # ie: Procfile.dev --port PORT
routes.default_url_options[:host] = '127.0.0.1' routes.default_url_options[:host] = '127.0.0.1'
require Rails.root.join('lib', 'formatters', 'ansi_formatter')
formatter = AnsiFormatter.new
config.semantic_logger.add_appender(io: $stdout,
formatter:,
filter: ->(log) { !formatter.reject(log) })
config.after_initialize do
formatter = Semantic::AnsiFormatter.new
config.semantic_logger.add_appender(io: $stdout,
formatter:,
filter: ->(log) { !formatter.reject(log) })
end
end end

2
config/initializers/monkey_patcher.rb

@ -1,4 +1,4 @@
return unless defined?(Rails::Server)
return unless Rails.application.server?
puts 'MonkeyPatcher runs:' puts 'MonkeyPatcher runs:'
patches = Dir.glob(Rails.root.join('lib', 'monkey_patches', '**', '*.rb')) patches = Dir.glob(Rails.root.join('lib', 'monkey_patches', '**', '*.rb'))

58
lib/formatters/ansi_colors.rb

@ -1,58 +0,0 @@
# common definitions and constants
module AnsiColors
ANSI_REGEX = /\e\[[0-9;]*m/ # TODO: support for \x1b and \033
# FORMAT
CLEAR = "\e[0m".freeze
BOLD = "\e[1m".freeze
UNDERLINE = "\e[4m".freeze
# TEXT
TEXT_BLACK = "\e[30m".freeze
TEXT_RED = "\e[31m".freeze
TEXT_GREEN = "\e[32m".freeze
TEXT_YELLOW = "\e[33m".freeze
TEXT_BLUE = "\e[34m".freeze
TEXT_MAGENTA = "\e[35m".freeze
TEXT_CYAN = "\e[36m".freeze
TEXT_WHITE = "\e[37m".freeze
# TEXT GRAY SHADES
TEXT_GRAY_800 = "\e[38;5;232m".freeze
TEXT_GRAY_700 = "\e[38;5;233m".freeze
TEXT_GRAY_600 = "\e[38;5;235m".freeze
TEXT_GRAY_500 = "\e[38;5;238m".freeze
TEXT_GRAY_400 = "\e[38;5;241m".freeze
TEXT_GRAY_300 = "\e[38;5;244m".freeze
TEXT_GRAY_200 = "\e[38;5;249m".freeze
TEXT_GRAY_100 = "\e[38;5;252m".freeze
# DARK TEXT
DARK_TEXT_BLACK = "\e[90m".freeze
DARK_TEXT_RED = "\e[91m".freeze
DARK_TEXT_GREEN = "\e[92m".freeze
DARK_TEXT_YELLOW = "\e[93m".freeze
DARK_TEXT_BLUE = "\e[94m".freeze
DARK_TEXT_MAGENTA = "\e[95m".freeze
DARK_TEXT_CYAN = "\e[96m".freeze
DARK_TEXT_WHITE = "\e[97m".freeze
# BACKGROUND
BG_BLACK = "\e[40m".freeze
BG_WHITE = "\e[47m".freeze
BG_GRAY = "\e[100m".freeze
BG_RED = "\e[41m".freeze
BG_GREEN = "\e[42m".freeze
BG_YELLOW = "\e[43m".freeze
BG_BLUE = "\e[44m".freeze
BG_MAGENTA = "\e[45m".freeze
BG_CYAN = "\e[46m".freeze
# DARK BACKGROUND
DARK_BG_RED = "\e[101m".freeze
DARK_BG_GREEN = "\e[102m".freeze
DARK_BG_YELLOW = "\e[103m".freeze
DARK_BG_BLUE = "\e[104m".freeze
DARK_BG_MAGENTA = "\e[105m".freeze
DARK_BG_CYAN = "\e[106m".freeze
end

20
lib/formatters/ansi_dimensions.rb

@ -1,20 +0,0 @@
require 'ostruct'
# extra dimensions for customizing the logging format
module AnsiDimensions
def self.new(rails: '╣x╠', before: 0, after: 0, terminus: false)
OpenStruct.new(rails:, before:, after:, terminus:) # rubocop:disable Style/OpenStructUse
end
def self.start
OpenStruct.new(rails: '╓─╖') # rubocop:disable Style/OpenStructUse
end
def self.end
OpenStruct.new(rails: '╣ ╠') # rubocop:disable Style/OpenStructUse
end
def self.around
OpenStruct.new(rails: '╣ ╠') # rubocop:disable Style/OpenStructUse
end
end

245
lib/formatters/ansi_formatter.rb

@ -1,245 +0,0 @@
require_relative 'ansi_wrapper'
require_relative 'ansi_colors'
require_relative 'ansi_dimensions'
require 'io/console'
require 'amazing_print'
require 'json'
# wraps meanwhile takes care of ansi colors
class AnsiFormatter < SemanticLogger::Formatters::Color
include AnsiColors
CENTER_SIZE = 20
FOREMAN_PREFIX_LENGTH = 18
FAILOVER_WRAP = 80
CHAR_FATAL = '⯶'.freeze
TERMINUS_STRING = '╙─╜'.freeze
RENDERED_VIEW_DURATION = 100
TOTAL_RENDERED_VIEW_DURATION = RENDERED_VIEW_DURATION * 5
def initialize
super(color_map:
ColorMap.new(
debug: CLEAR + TEXT_GRAY_400,
info: CLEAR + TEXT_GRAY_100,
warn: CLEAR + BG_YELLOW + TEXT_BLACK,
error: CLEAR + BG_RED + TEXT_WHITE,
fatal: CLEAR + BG_MAGENTA + BOLD + TEXT_WHITE
))
@memory = nil
end
def call(log, logger)
log = alter(log)
self.log = log
self.logger = logger
self.color = color_map[log.level]
wrap_level(compute_useful_length, message, payload, exception)
end
def reject(log)
return true if log.name == 'ActionView::Base' && log.message&.starts_with?(' Rendering')
true if log.name == 'Rails' && log.message&.starts_with?('Loaded')
end
private
def build_regex_redirected
dev_port = Rails.application.routes.default_url_options[:port]
dev_hosts = ['127.0.0.1', 'localhost', Rails.application.routes.default_url_options[:host]].uniq.join('|')
dev_from = "http://(?:#{dev_hosts}):#{dev_port}"
regex_s = "^(Redirected to )#{dev_from}(.*)"
Regexp.new(regex_s)
end
def ansi_trace(trace, symbol)
match = trace.match(/(↳ )?(.*:\d+)(:in `)?(.*'?)/) # only m2(=file) and m4(=optional function) are useful
return trace unless match
_, m2, _, m4 = match.captures
"#{symbol} #{m2} #{BOLD}#{m4.chop}#{CLEAR}"
end
def two_captures_last_as_bold(message, regex)
match = message.match(regex)
return "unmatched: #{message}" unless match
m1, m2 = match.captures
"#{m1}#{BOLD}#{m2}#{CLEAR}"
end
def alter(log)
if log.name == 'Rails'
if log.message
log.message.lstrip!
log.message.chomp!('')
if log.message.starts_with?('Started')
rails = '╓─╖'
before = 1
if @memory
rails = "#{@memory}"
before = 0
end
log.dimensions = AnsiDimensions.new(rails:, before:)
@memory = nil
log.message = two_captures_last_as_bold(log.message, /(^Started \w* )"(.*?)"/)
elsif log.message.starts_with?('Parameters')
parameters = log.message.match(/Parameters: ({.*}$)/).match(1)
parameters = JSON.parse(parameters.gsub('=>', ':'), symbolize_names: true)
parameters = parameters.ai(ruby19_syntax: true, plain: true, multiline: false)
parameters = parameters.gsub(/\w*:/) { |key| "#{TEXT_GRAY_200}#{key.chop}#{color_map[:debug]}:" }
parameters = parameters.gsub(/: ".*?"/) do |value|
": \"#{TEXT_YELLOW}#{value[3..-2]}#{color_map[:debug]}\""
end
log.message = "Parameters: #{parameters}"
log.level = :debug
elsif log.message.starts_with?('Completed')
m1, m2, m3, m4 = log.message.match(/^Completed (\d+) (.*) in (\d+\.?\d*)ms (.*)$/).captures
http_code = m1.to_i
duration = m3.to_f
duration = duration > TOTAL_RENDERED_VIEW_DURATION ? " in #{m3}ms #{m4}" : ''
log.message = "Completed #{BOLD}#{http_code}#{CLEAR} #{m2}#{duration}"
if http_code / 100 == 2
log.dimensions = AnsiDimensions.new(rails: TERMINUS_STRING, after: 1)
elsif http_code / 100 == 3
@memory = '║'
log.dimensions = AnsiDimensions.new(rails: "#{@memory}")
elsif http_code / 100 == 4
log.dimensions = AnsiDimensions.new(rails: '╙╨╜')
elsif http_code / 100 == 5
log.dimensions = AnsiDimensions.new(rails: "#{draw_fatal}")
end
elsif log.message =~ /^(Processing|Parameters)/
log.level = :debug
if log.message =~ /^Processing/
log.message = two_captures_last_as_bold(log.message, /(^Processing by \w*#\w* as )(.*)/)
end
elsif log.message =~ /Redirected/
log.level = :debug
@regex_redirected ||= build_regex_redirected # lazy building
log.message = two_captures_last_as_bold(log.message, @regex_redirected)
end
elsif log.exception
log.dimensions = AnsiDimensions.new(
rails: "#{draw_fatal(log.level.to_s.chr.upcase)}",
after: 1,
terminus: true
)
end
elsif log.name =~ /^(ActionView|ActiveRecord)::Base/
log.level = :debug
log.message.lstrip!
if log.name == 'ActiveRecord::Base'
unbold!(log.message)
if log.message.starts_with?('↳ ')
log.message = ansi_trace(log.message, '⇄')
else
sql_match = log.message.match(/\s+\[\[.*\]\]$/)
if sql_match
sql_command = sql_match.pre_match
sql_type, sql_entry = sql_command.split("\e[0m")
sql_color_entry = sql_entry.match(ANSI_REGEX).to_s
sql_args = JSON.parse(sql_match.to_s).map(&:last)
sql_args.each_with_index do |val, index|
sql_arg = val.inspect
sql_arg = "'#{val.gsub("'", "''")}'" if val.is_a?(String)
sql_entry.gsub!("$#{index + 1}", colorize(sql_arg, BOLD) + sql_color_entry)
end
log.message = [sql_type, sql_entry].join(CLEAR)
end
end
elsif log.name == 'ActionView::Base'
match = log.message.match(/^Rendered( layout| collection of|) (.*?\.erb)(.*)(\(Duration:.*\))/)
if match
m1, m2, m3, m4 = match.captures
duration = m4.match(/Duration: (\d+\.?\d*)ms/).match(1).to_f
duration = duration < RENDERED_VIEW_DURATION ? '' : m4.to_s
log.message = "⇤ Rendered#{m1} app/views/#{m2}#{m3}#{duration}"
end
end
end
log
end
def draw_fatal(char = CHAR_FATAL)
BG_MAGENTA + BOLD + TEXT_WHITE + char + CLEAR
end
def origin = colorize(centerize(log.name), TEXT_CYAN)
def build_prefix(char) = "#{origin}#{colorize(char)}"
def build_terminus = "#{origin} #{TERMINUS_STRING} "
def centerize(text) = text.truncate(CENTER_SIZE).center(CENTER_SIZE)
def colorize(text, tint = color) = "#{tint}#{text}#{CLEAR}"
def stackisize(items)
return '' if items.empty?
traces = items.map { |item| ansi_trace(item, '➟') }
"\n#{traces.join("\n")}"
end
def build_dimensions(dimensions)
"#{origin} #{dimensions.rails} "
end
def compute_useful_length
IO.console.winsize[1] - FOREMAN_PREFIX_LENGTH
rescue StandardError
FAILOVER_WRAP
end
def unbold!(text) = text.gsub!(BOLD, '')
def message
colorize(log.message) if log.message
end
def payload
return unless log.payload
log.payload.ai(indent: 2, object_id: false)
end
def exception
return unless log.exception
exc = log.exception
clazz = colorize("#{exc.class}\n", color_map[:fatal])
message = colorize(exc.message.chomp(''), color_map[:error])
backtrace = stackisize(Rails.backtrace_cleaner.clean(exc.backtrace))
"#{clazz}#{message}#{backtrace}"
end
def level_char
case log.level
when :info, :debug then ' '
else log.level.to_s.chr.upcase
end
end
def wrap_level(length, *items)
prefix = log.dimensions ? build_dimensions(log.dimensions) : build_prefix(level_char)
continuation = build_prefix('┆')
result = items.map do |item|
AnsiWrapper.wrap(item, length, prefix, continuation)
end
if log.dimensions&.terminus
terminus = AnsiWrapper.wrap(' ', length, build_terminus)
result << terminus
end
log.dimensions&.before&.times { result.unshift('') }
log.dimensions&.after&.times { result << '' }
result.compact.join("\n")
end
end

80
lib/formatters/ansi_wrapper.rb

@ -1,80 +0,0 @@
require_relative 'ansi_colors'
# AnsiWrapper cares about Ansi Colour Code \e[...
class AnsiWrapper
include AnsiColors
TAB_TO_SPACES = 2
def self.wrap(text, length, prefix = '', continuation = prefix)
if visible_length(prefix) != visible_length(continuation)
raise "continuation <#{continuation.inspect}> should have the same length as prefix <#{prefix.inspect}>"
end
return unless text
text = text.gsub("\t", ' ' * TAB_TO_SPACES)
lines = split_text_to_lines(text, length - visible_length(prefix))
lines = inject_continuation_and_ansi_colors_to_lines(lines, prefix, continuation)
lines.join("\n")
end
private_class_method def self.inject_continuation_and_ansi_colors_to_lines(lines, prefix, continuation)
last_ansi = ''
lines.each_with_index.map do |line, index|
current = index.zero? ? prefix : continuation
current += last_ansi unless last_ansi.empty? || last_ansi == CLEAR
current += line
last_ansi = scan_for_actual_ansi(line, last_ansi)
current += CLEAR if last_ansi.empty? || last_ansi != CLEAR
current
end
end
private_class_method def self.scan_for_actual_ansi(line, last_ansi)
line.scan(ANSI_REGEX).each do |match|
ansi_code = match.to_s
if ansi_code == CLEAR
last_ansi = CLEAR
else
last_ansi += ansi_code
end
end
last_ansi
end
private_class_method def self.split_text_to_lines(text, length)
lines = text.split("\n")
sublines = lines.map do |line|
visible_length(line) > length ? visible_split(line, length) : [line]
end
sublines.flatten
end
private_class_method def self.visible_length(line)
raise 'line should not contain carriage return character!' if line.match "\n"
ansi_code_length = line.scan(ANSI_REGEX).map(&:length).sum
line.length - ansi_code_length
end
# TODO: might be refactored with less complexity
private_class_method def self.visible_split(line, length, stack = '') # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
before, ansi_code, after = line.partition(ANSI_REGEX)
stack_length = visible_length(stack)
visible_length = before.length + stack_length
if visible_length == length
["#{stack}#{before}#{ansi_code}"] + visible_split(after, length)
elsif visible_length > length
first_line = stack + before[0...length - stack_length]
tail = before[length - stack_length..] + ansi_code + after
[first_line] + visible_split(tail, length)
elsif ansi_code.length.positive?
visible_split(after, length, "#{stack}#{before}#{ansi_code}")
else
["#{stack}#{before}#{ansi_code}"]
end
end
end

82
lib/formatters/basic_formatter.rb

@ -1,82 +0,0 @@
# My Custom colorized formatter
class BasicFormatter < SemanticLogger::Formatters::Color
ANSI_REVERSED_WARNING = "\e[0;30;43m".freeze
ANSI_REVERSED_ERROR = "\e[1;30;41m".freeze
ANSI_GRAY = "\e[90m".freeze
# Return the complete log level name in uppercase
def initialize
super(time_format: '%H:%M:%S',
color_map: ColorMap.new(
debug: ANSI_GRAY,
info: SemanticLogger::AnsiColors::GREEN,
warn: SemanticLogger::AnsiColors::YELLOW
))
@time_format = nil if File.exist?(File.join(Rails.root, 'Procfile.dev'))
end
def time
"#{color}#{format_time(log.time)}#{color_map.clear}" if time_format
end
def message
return unless log.message
prefix = "#{color}--#{color_map.clear}"
case log.level
when :info
message = log.message
message = message&.rstrip if log.name == 'Rails' && message.starts_with?('Completed')
if log.name == 'Rails' && message.starts_with?('Started')
message = message.split('for')[0]
puts '' if Rails.env.development?
end
if log.name == 'Rails' || log.name == 'ActionView::Base'
"#{prefix} #{ANSI_GRAY}#{message}#{color_map.clear}"
else
"#{prefix} #{SemanticLogger::AnsiColors::WHITE}#{message}#{color_map.clear}"
end
when :warn
"#{prefix} #{ANSI_REVERSED_WARNING}#{log.message}#{color_map.clear}"
when :error, :fatal
"#{prefix} #{ANSI_REVERSED_ERROR}#{log.message}#{color_map.clear}"
else
"#{prefix} #{color}#{log.message}#{color_map.clear}"
end
end
def tags; end
def process_info
fname = file_name_and_line
"#{color}[#{fname}]#{color_map.clear}" if fname
end
def exception
return unless log.exception
root_path = Rails.root.to_s
backtrace = log.exception.backtrace.select do |line|
line.starts_with?(root_path)
end
if backtrace.count.positive?
"-- #{ANSI_REVERSED_ERROR}#{log.exception.class}#{color_map.clear} #{color}#{log.exception.message}#{color_map.clear}#{SemanticLogger::AnsiColors::WHITE}\n\t#{backtrace.join("\n\t")}#{color_map.clear}\n\n"
else
"-- #{ANSI_REVERSED_ERROR}#{log.exception.class}: #{log.exception.message}#{color_map.clear}\n\n"
end
end
def call(log, logger)
self.color = color_map[log.level]
self.log = log
self.logger = logger
if @time_format
[time, level, process_info, tags, named_tags, duration, name, message, payload, exception].compact.join(' ')
else
[tags, named_tags, duration, name, message, payload, exception].compact.join(' ')
end
end
end

60
lib/semantic/ansi_colors.rb

@ -0,0 +1,60 @@
module Semantic
# common definitions and constants
module AnsiColors
ANSI_REGEX = /\e\[[0-9;]*m/ # TODO: support for \x1b and \033
# FORMAT
CLEAR = "\e[0m".freeze
BOLD = "\e[1m".freeze
UNDERLINE = "\e[4m".freeze
# TEXT
TEXT_BLACK = "\e[30m".freeze
TEXT_RED = "\e[31m".freeze
TEXT_GREEN = "\e[32m".freeze
TEXT_YELLOW = "\e[33m".freeze
TEXT_BLUE = "\e[34m".freeze
TEXT_MAGENTA = "\e[35m".freeze
TEXT_CYAN = "\e[36m".freeze
TEXT_WHITE = "\e[37m".freeze
# TEXT GRAY SHADES
TEXT_GRAY_800 = "\e[38;5;232m".freeze
TEXT_GRAY_700 = "\e[38;5;233m".freeze
TEXT_GRAY_600 = "\e[38;5;235m".freeze
TEXT_GRAY_500 = "\e[38;5;238m".freeze
TEXT_GRAY_400 = "\e[38;5;241m".freeze
TEXT_GRAY_300 = "\e[38;5;244m".freeze
TEXT_GRAY_200 = "\e[38;5;249m".freeze
TEXT_GRAY_100 = "\e[38;5;252m".freeze
# DARK TEXT
DARK_TEXT_BLACK = "\e[90m".freeze
DARK_TEXT_RED = "\e[91m".freeze
DARK_TEXT_GREEN = "\e[92m".freeze
DARK_TEXT_YELLOW = "\e[93m".freeze
DARK_TEXT_BLUE = "\e[94m".freeze
DARK_TEXT_MAGENTA = "\e[95m".freeze
DARK_TEXT_CYAN = "\e[96m".freeze
DARK_TEXT_WHITE = "\e[97m".freeze
# BACKGROUND
BG_BLACK = "\e[40m".freeze
BG_WHITE = "\e[47m".freeze
BG_GRAY = "\e[100m".freeze
BG_RED = "\e[41m".freeze
BG_GREEN = "\e[42m".freeze
BG_YELLOW = "\e[43m".freeze
BG_BLUE = "\e[44m".freeze
BG_MAGENTA = "\e[45m".freeze
BG_CYAN = "\e[46m".freeze
# DARK BACKGROUND
DARK_BG_RED = "\e[101m".freeze
DARK_BG_GREEN = "\e[102m".freeze
DARK_BG_YELLOW = "\e[103m".freeze
DARK_BG_BLUE = "\e[104m".freeze
DARK_BG_MAGENTA = "\e[105m".freeze
DARK_BG_CYAN = "\e[106m".freeze
end
end

22
lib/semantic/ansi_dimensions.rb

@ -0,0 +1,22 @@
require 'ostruct'
module Semantic
# extra dimensions for customizing the logging format
module AnsiDimensions
def self.new(rails: '╣x╠', before: 0, after: 0, terminus: false)
OpenStruct.new(rails:, before:, after:, terminus:) # rubocop:disable Style/OpenStructUse
end
def self.start
OpenStruct.new(rails: '╓─╖') # rubocop:disable Style/OpenStructUse
end
def self.end
OpenStruct.new(rails: '╣ ╠') # rubocop:disable Style/OpenStructUse
end
def self.around
OpenStruct.new(rails: '╣ ╠') # rubocop:disable Style/OpenStructUse
end
end
end

247
lib/semantic/ansi_formatter.rb

@ -0,0 +1,247 @@
require_relative 'ansi_wrapper'
require_relative 'ansi_colors'
require_relative 'ansi_dimensions'
require 'io/console'
require 'amazing_print'
require 'json'
module Semantic
# wraps meanwhile takes care of ansi colors
class AnsiFormatter < SemanticLogger::Formatters::Color
include AnsiColors
CENTER_SIZE = 20
FOREMAN_PREFIX_LENGTH = 18
FAILOVER_WRAP = 80
CHAR_FATAL = '⯶'.freeze
TERMINUS_STRING = '╙─╜'.freeze
RENDERED_VIEW_DURATION = 100
TOTAL_RENDERED_VIEW_DURATION = RENDERED_VIEW_DURATION * 5
def initialize
super(color_map:
ColorMap.new(
debug: CLEAR + TEXT_GRAY_400,
info: CLEAR + TEXT_GRAY_100,
warn: CLEAR + BG_YELLOW + TEXT_BLACK,
error: CLEAR + BG_RED + TEXT_WHITE,
fatal: CLEAR + BG_MAGENTA + BOLD + TEXT_WHITE
))
@memory = nil
end
def call(log, logger)
log = alter(log)
self.log = log
self.logger = logger
self.color = color_map[log.level]
wrap_level(compute_useful_length, message, payload, exception)
end
def reject(log)
return true if log.name == 'ActionView::Base' && log.message&.starts_with?(' Rendering')
true if log.name == 'Rails' && log.message&.starts_with?('Loaded')
end
private
def build_regex_redirected
dev_port = Rails.application.routes.default_url_options[:port]
dev_hosts = ['127.0.0.1', 'localhost', Rails.application.routes.default_url_options[:host]].uniq.join('|')
dev_from = "http://(?:#{dev_hosts}):#{dev_port}"
regex_s = "^(Redirected to )#{dev_from}(.*)"
Regexp.new(regex_s)
end
def ansi_trace(trace, symbol)
match = trace.match(/(↳ )?(.*:\d+)(:in `)?(.*'?)/) # only m2(=file) and m4(=optional function) are useful
return trace unless match
_, m2, _, m4 = match.captures
"#{symbol} #{m2} #{BOLD}#{m4.chop}#{CLEAR}"
end
def two_captures_last_as_bold(message, regex)
match = message.match(regex)
return "unmatched: #{message}" unless match
m1, m2 = match.captures
"#{m1}#{BOLD}#{m2}#{CLEAR}"
end
def alter(log)
if log.name == 'Rails'
if log.message
log.message.lstrip!
log.message.chomp!('')
if log.message.starts_with?('Started')
rails = '╓─╖'
before = 1
if @memory
rails = "#{@memory}"
before = 0
end
log.dimensions = AnsiDimensions.new(rails:, before:)
@memory = nil
log.message = two_captures_last_as_bold(log.message, /(^Started \w* )"(.*?)"/)
elsif log.message.starts_with?('Parameters')
parameters = log.message.match(/Parameters: ({.*}$)/).match(1)
parameters = JSON.parse(parameters.gsub('=>', ':'), symbolize_names: true)
parameters = parameters.ai(ruby19_syntax: true, plain: true, multiline: false)
parameters = parameters.gsub(/\w*:/) { |key| "#{TEXT_GRAY_200}#{key.chop}#{color_map[:debug]}:" }
parameters = parameters.gsub(/: ".*?"/) do |value|
": \"#{TEXT_YELLOW}#{value[3..-2]}#{color_map[:debug]}\""
end
log.message = "Parameters: #{parameters}"
log.level = :debug
elsif log.message.starts_with?('Completed')
m1, m2, m3, m4 = log.message.match(/^Completed (\d+) (.*) in (\d+\.?\d*)ms (.*)$/).captures
http_code = m1.to_i
duration = m3.to_f
duration = duration > TOTAL_RENDERED_VIEW_DURATION ? " in #{m3}ms #{m4}" : ''
log.message = "Completed #{BOLD}#{http_code}#{CLEAR} #{m2}#{duration}"
if http_code / 100 == 2
log.dimensions = AnsiDimensions.new(rails: TERMINUS_STRING, after: 1)
elsif http_code / 100 == 3
@memory = '║'
log.dimensions = AnsiDimensions.new(rails: "#{@memory}")
elsif http_code / 100 == 4
log.dimensions = AnsiDimensions.new(rails: '╙╨╜')
elsif http_code / 100 == 5
log.dimensions = AnsiDimensions.new(rails: "#{draw_fatal}")
end
elsif log.message =~ /^(Processing|Parameters)/
log.level = :debug
if log.message =~ /^Processing/
log.message = two_captures_last_as_bold(log.message, /(^Processing by \w*#\w* as )(.*)/)
end
elsif log.message =~ /Redirected/
log.level = :debug
@regex_redirected ||= build_regex_redirected # lazy building
log.message = two_captures_last_as_bold(log.message, @regex_redirected)
end
elsif log.exception
log.dimensions = AnsiDimensions.new(
rails: "#{draw_fatal(log.level.to_s.chr.upcase)}",
after: 1,
terminus: true
)
end
elsif log.name =~ /^(ActionView|ActiveRecord)::Base/
log.level = :debug
log.message.lstrip!
if log.name == 'ActiveRecord::Base'
unbold!(log.message)
if log.message.starts_with?('↳ ')
log.message = ansi_trace(log.message, '⇄')
else
sql_match = log.message.match(/\s+\[\[.*\]\]$/)
if sql_match
sql_command = sql_match.pre_match
sql_type, sql_entry = sql_command.split("\e[0m")
sql_color_entry = sql_entry.match(ANSI_REGEX).to_s
sql_args = JSON.parse(sql_match.to_s).map(&:last)
sql_args.each_with_index do |val, index|
sql_arg = val.inspect
sql_arg = "'#{val.gsub("'", "''")}'" if val.is_a?(String)
sql_entry.gsub!("$#{index + 1}", colorize(sql_arg, BOLD) + sql_color_entry)
end
log.message = [sql_type, sql_entry].join(CLEAR)
end
end
elsif log.name == 'ActionView::Base'
match = log.message.match(/^Rendered( layout| collection of|) (.*?\.erb)(.*)(\(Duration:.*\))/)
if match
m1, m2, m3, m4 = match.captures
duration = m4.match(/Duration: (\d+\.?\d*)ms/).match(1).to_f
duration = duration < RENDERED_VIEW_DURATION ? '' : m4.to_s
log.message = "⇤ Rendered#{m1} app/views/#{m2}#{m3}#{duration}"
end
end
end
log
end
def draw_fatal(char = CHAR_FATAL)
BG_MAGENTA + BOLD + TEXT_WHITE + char + CLEAR
end
def origin = colorize(centerize(log.name), TEXT_CYAN)
def build_prefix(char) = "#{origin}#{colorize(char)}"
def build_terminus = "#{origin} #{TERMINUS_STRING} "
def centerize(text) = text.truncate(CENTER_SIZE).center(CENTER_SIZE)
def colorize(text, tint = color) = "#{tint}#{text}#{CLEAR}"
def stackisize(items)
return '' if items.empty?
traces = items.map { |item| ansi_trace(item, '➟') }
"\n#{traces.join("\n")}"
end
def build_dimensions(dimensions)
"#{origin} #{dimensions.rails} "
end
def compute_useful_length
IO.console.winsize[1] - FOREMAN_PREFIX_LENGTH
rescue StandardError
FAILOVER_WRAP
end
def unbold!(text) = text.gsub!(BOLD, '')
def message
colorize(log.message) if log.message
end
def payload
return unless log.payload
log.payload.ai(indent: 2, object_id: false)
end
def exception
return unless log.exception
exc = log.exception
clazz = colorize("#{exc.class}\n", color_map[:fatal])
message = colorize(exc.message.chomp(''), color_map[:error])
backtrace = stackisize(Rails.backtrace_cleaner.clean(exc.backtrace))
"#{clazz}#{message}#{backtrace}"
end
def level_char
case log.level
when :info, :debug then ' '
else log.level.to_s.chr.upcase
end
end
def wrap_level(length, *items)
prefix = log.dimensions ? build_dimensions(log.dimensions) : build_prefix(level_char)
continuation = build_prefix('┆')
result = items.map do |item|
AnsiWrapper.wrap(item, length, prefix, continuation)
end
if log.dimensions&.terminus
terminus = AnsiWrapper.wrap(' ', length, build_terminus)
result << terminus
end
log.dimensions&.before&.times { result.unshift('') }
log.dimensions&.after&.times { result << '' }
result.compact.join("\n")
end
end
end

82
lib/semantic/ansi_wrapper.rb

@ -0,0 +1,82 @@
require_relative 'ansi_colors'
module Semantic
# AnsiWrapper cares about Ansi Colour Code \e[...
class AnsiWrapper
include AnsiColors
TAB_TO_SPACES = 2
def self.wrap(text, length, prefix = '', continuation = prefix)
if visible_length(prefix) != visible_length(continuation)
raise "continuation <#{continuation.inspect}> should have the same length as prefix <#{prefix.inspect}>"
end
return unless text
text = text.gsub("\t", ' ' * TAB_TO_SPACES)
lines = split_text_to_lines(text, length - visible_length(prefix))
lines = inject_continuation_and_ansi_colors_to_lines(lines, prefix, continuation)
lines.join("\n")
end
private_class_method def self.inject_continuation_and_ansi_colors_to_lines(lines, prefix, continuation)
last_ansi = ''
lines.each_with_index.map do |line, index|
current = index.zero? ? prefix : continuation
current += last_ansi unless last_ansi.empty? || last_ansi == CLEAR
current += line
last_ansi = scan_for_actual_ansi(line, last_ansi)
current += CLEAR if last_ansi.empty? || last_ansi != CLEAR
current
end
end
private_class_method def self.scan_for_actual_ansi(line, last_ansi)
line.scan(ANSI_REGEX).each do |match|
ansi_code = match.to_s
if ansi_code == CLEAR
last_ansi = CLEAR
else
last_ansi += ansi_code
end
end
last_ansi
end
private_class_method def self.split_text_to_lines(text, length)
lines = text.split("\n")
sublines = lines.map do |line|
visible_length(line) > length ? visible_split(line, length) : [line]
end
sublines.flatten
end
private_class_method def self.visible_length(line)
raise 'line should not contain carriage return character!' if line.match "\n"
ansi_code_length = line.scan(ANSI_REGEX).map(&:length).sum
line.length - ansi_code_length
end
# TODO: might be refactored with less complexity
private_class_method def self.visible_split(line, length, stack = '') # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
before, ansi_code, after = line.partition(ANSI_REGEX)
stack_length = visible_length(stack)
visible_length = before.length + stack_length
if visible_length == length
["#{stack}#{before}#{ansi_code}"] + visible_split(after, length)
elsif visible_length > length
first_line = stack + before[0...length - stack_length]
tail = before[length - stack_length..] + ansi_code + after
[first_line] + visible_split(tail, length)
elsif ansi_code.length.positive?
visible_split(after, length, "#{stack}#{before}#{ansi_code}")
else
["#{stack}#{before}#{ansi_code}"]
end
end
end
end

84
lib/semantic/basic_formatter.rb

@ -0,0 +1,84 @@
module Semantic
# My Custom colorized formatter
class BasicFormatter < SemanticLogger::Formatters::Color
ANSI_REVERSED_WARNING = "\e[0;30;43m".freeze
ANSI_REVERSED_ERROR = "\e[1;30;41m".freeze
ANSI_GRAY = "\e[90m".freeze
# Return the complete log level name in uppercase
def initialize
super(time_format: '%H:%M:%S',
color_map: ColorMap.new(
debug: ANSI_GRAY,
info: SemanticLogger::AnsiColors::GREEN,
warn: SemanticLogger::AnsiColors::YELLOW
))
@time_format = nil if File.exist?(File.join(Rails.root, 'Procfile.dev'))
end
def time
"#{color}#{format_time(log.time)}#{color_map.clear}" if time_format
end
def message
return unless log.message
prefix = "#{color}--#{color_map.clear}"
case log.level
when :info
message = log.message
message = message&.rstrip if log.name == 'Rails' && message.starts_with?('Completed')
if log.name == 'Rails' && message.starts_with?('Started')
message = message.split('for')[0]
puts '' if Rails.env.development?
end
if log.name == 'Rails' || log.name == 'ActionView::Base'
"#{prefix} #{ANSI_GRAY}#{message}#{color_map.clear}"
else
"#{prefix} #{SemanticLogger::AnsiColors::WHITE}#{message}#{color_map.clear}"
end
when :warn
"#{prefix} #{ANSI_REVERSED_WARNING}#{log.message}#{color_map.clear}"
when :error, :fatal
"#{prefix} #{ANSI_REVERSED_ERROR}#{log.message}#{color_map.clear}"
else
"#{prefix} #{color}#{log.message}#{color_map.clear}"
end
end
def tags; end
def process_info
fname = file_name_and_line
"#{color}[#{fname}]#{color_map.clear}" if fname
end
def exception
return unless log.exception
root_path = Rails.root.to_s
backtrace = log.exception.backtrace.select do |line|
line.starts_with?(root_path)
end
if backtrace.count.positive?
"-- #{ANSI_REVERSED_ERROR}#{log.exception.class}#{color_map.clear} #{color}#{log.exception.message}#{color_map.clear}#{SemanticLogger::AnsiColors::WHITE}\n\t#{backtrace.join("\n\t")}#{color_map.clear}\n\n"
else
"-- #{ANSI_REVERSED_ERROR}#{log.exception.class}: #{log.exception.message}#{color_map.clear}\n\n"
end
end
def call(log, logger)
self.color = color_map[log.level]
self.log = log
self.logger = logger
if @time_format
[time, level, process_info, tags, named_tags, duration, name, message, payload, exception].compact.join(' ')
else
[tags, named_tags, duration, name, message, payload, exception].compact.join(' ')
end
end
end
end
Loading…
Cancel
Save