From b1ba5272b8bcf76ed6bcd10fd50e968cccec2f97 Mon Sep 17 00:00:00 2001 From: pvincent Date: Sun, 25 Aug 2024 23:16:21 +0400 Subject: [PATCH] command_detection --- Procfile.dev | 2 +- config/application.rb | 5 +- config/command_detection.rb | 7 + config/environments/development.rb | 11 +- config/initializers/monkey_patcher.rb | 2 +- lib/formatters/ansi_colors.rb | 58 ------ lib/formatters/ansi_dimensions.rb | 20 --- lib/formatters/ansi_formatter.rb | 245 ------------------------- lib/formatters/ansi_wrapper.rb | 80 --------- lib/formatters/basic_formatter.rb | 82 --------- lib/semantic/ansi_colors.rb | 60 +++++++ lib/semantic/ansi_dimensions.rb | 22 +++ lib/semantic/ansi_formatter.rb | 247 ++++++++++++++++++++++++++ lib/semantic/ansi_wrapper.rb | 82 +++++++++ lib/semantic/basic_formatter.rb | 84 +++++++++ 15 files changed, 514 insertions(+), 493 deletions(-) create mode 100644 config/command_detection.rb delete mode 100644 lib/formatters/ansi_colors.rb delete mode 100644 lib/formatters/ansi_dimensions.rb delete mode 100644 lib/formatters/ansi_formatter.rb delete mode 100644 lib/formatters/ansi_wrapper.rb delete mode 100644 lib/formatters/basic_formatter.rb create mode 100644 lib/semantic/ansi_colors.rb create mode 100644 lib/semantic/ansi_dimensions.rb create mode 100644 lib/semantic/ansi_formatter.rb create mode 100644 lib/semantic/ansi_wrapper.rb create mode 100644 lib/semantic/basic_formatter.rb diff --git a/Procfile.dev b/Procfile.dev index e8fbe3c..21de3cc 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,3 @@ 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 diff --git a/config/application.rb b/config/application.rb index f122478..be492e4 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,4 +1,5 @@ require_relative 'boot' +require_relative 'command_detection' require 'rails/all' # Require the gems listed in Gemfile, including any gems @@ -9,8 +10,10 @@ Bundler.require(*Rails.groups) module EasyGoingRails # Main Application class Application < Rails::Application + include CommandDetection + # 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 # not contain `.rb` files, or that should not be reloaded or eager loaded. diff --git a/config/command_detection.rb b/config/command_detection.rb new file mode 100644 index 0000000..3aa5996 --- /dev/null +++ b/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 diff --git a/config/environments/development.rb b/config/environments/development.rb index 3369673..7b947f4 100644 --- a/config/environments/development.rb +++ b/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[: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 diff --git a/config/initializers/monkey_patcher.rb b/config/initializers/monkey_patcher.rb index f3b5e0f..c15ef15 100644 --- a/config/initializers/monkey_patcher.rb +++ b/config/initializers/monkey_patcher.rb @@ -1,4 +1,4 @@ -return unless defined?(Rails::Server) +return unless Rails.application.server? puts 'MonkeyPatcher runs:' patches = Dir.glob(Rails.root.join('lib', 'monkey_patches', '**', '*.rb')) diff --git a/lib/formatters/ansi_colors.rb b/lib/formatters/ansi_colors.rb deleted file mode 100644 index 44329de..0000000 --- a/lib/formatters/ansi_colors.rb +++ /dev/null @@ -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 diff --git a/lib/formatters/ansi_dimensions.rb b/lib/formatters/ansi_dimensions.rb deleted file mode 100644 index da660c7..0000000 --- a/lib/formatters/ansi_dimensions.rb +++ /dev/null @@ -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 diff --git a/lib/formatters/ansi_formatter.rb b/lib/formatters/ansi_formatter.rb deleted file mode 100644 index 1763d6e..0000000 --- a/lib/formatters/ansi_formatter.rb +++ /dev/null @@ -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 diff --git a/lib/formatters/ansi_wrapper.rb b/lib/formatters/ansi_wrapper.rb deleted file mode 100644 index ab44652..0000000 --- a/lib/formatters/ansi_wrapper.rb +++ /dev/null @@ -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 diff --git a/lib/formatters/basic_formatter.rb b/lib/formatters/basic_formatter.rb deleted file mode 100644 index 8769715..0000000 --- a/lib/formatters/basic_formatter.rb +++ /dev/null @@ -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 diff --git a/lib/semantic/ansi_colors.rb b/lib/semantic/ansi_colors.rb new file mode 100644 index 0000000..517774b --- /dev/null +++ b/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 diff --git a/lib/semantic/ansi_dimensions.rb b/lib/semantic/ansi_dimensions.rb new file mode 100644 index 0000000..cb0620f --- /dev/null +++ b/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 diff --git a/lib/semantic/ansi_formatter.rb b/lib/semantic/ansi_formatter.rb new file mode 100644 index 0000000..3d5d5f9 --- /dev/null +++ b/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 diff --git a/lib/semantic/ansi_wrapper.rb b/lib/semantic/ansi_wrapper.rb new file mode 100644 index 0000000..cd0a539 --- /dev/null +++ b/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 diff --git a/lib/semantic/basic_formatter.rb b/lib/semantic/basic_formatter.rb new file mode 100644 index 0000000..bd34053 --- /dev/null +++ b/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