pvincent
2 months ago
20 changed files with 199 additions and 326 deletions
-
2app/views/layouts/application.html.erb
-
3app/views/scores/index.html.erb
-
20config/application.rb
-
17config/environments/development.rb
-
9config/initializers/hot_changes.rb
-
14config/initializers/monkey_patcher.rb
-
12config/initializers/monkey_patches.rb
-
13config/initializers/rails_live_reload.rb
-
21config/initializers/semantic_logger.rb
-
104lib/hot/constants.rb
-
8lib/hot/live.rb
-
13lib/live/constants.rb
-
118lib/live/definable.rb
-
17lib/monkey_patches/monkey_patcher.rb
-
29lib/monkey_patches/rails_live_reload/watcher.rb
-
4lib/semantic/basic_formatter.rb
-
76lib/semantic/dev_loader.rb
-
2lib/semantic/fancy_dimensions.rb
-
4lib/semantic/fancy_formatter.rb
-
39lib/semantic/notification_util.rb
@ -1,9 +0,0 @@ |
|||
return unless Rails.application.server? |
|||
|
|||
Rails.application.config.after_initialize do |
|||
Hot::Constants.on_change(:log_active_record) { |bool| ActiveRecord::Base.logger.level = bool ? :debug : :fatal } |
|||
Hot::Constants.on_change(:log_action_view) do |bool| |
|||
ActionView::Base.logger.level = bool ? :debug : :fatal |
|||
Rails.application.config.dev_loader.launch |
|||
end |
|||
end |
@ -1,14 +0,0 @@ |
|||
return unless Rails.application.server? |
|||
|
|||
# puts 'MonkeyPatcher runs:' |
|||
patches = Dir.glob(Rails.root.join('lib', 'monkey_patches', '**', '*.rb')) |
|||
patches.each do |file| |
|||
# puts "🐵 patching... #{Pathname.new(file).relative_path_from Rails.root}" |
|||
require file |
|||
end |
|||
|
|||
# puts case patches.count |
|||
# when 0 then 'No patch found' |
|||
# when 1 then '1 successful patch applied' |
|||
# else "#{patches.count} successful patches applied" |
|||
# end |
@ -0,0 +1,12 @@ |
|||
return unless Rails.application.server? || Rails.application.console? |
|||
|
|||
require_relative '../../lib/monkey_patches/monkey_patcher' |
|||
|
|||
MonkeyPatcher.run do |patch| |
|||
case patch |
|||
when 'action_dispatch/middleware/debug_exceptions.rb' then Rails.application.server? |
|||
when 'rails_live_reload/watcher.rb' then Rails.application.server? && Rails.env.development? |
|||
when /^semantic/ then true |
|||
else false |
|||
end |
|||
end |
@ -1,16 +1,11 @@ |
|||
return unless defined?(RailsLiveReload) && Rails.env.development? |
|||
|
|||
# RailsLiveReload |
|||
RailsLiveReload.configure do |config| |
|||
# HOT Constants |
|||
config.watch(%r{/\.env$}, reload: :always) |
|||
config.watch(%r{/\.env\.sample$}) |
|||
# config.watch(%r{/config/initializers/hot_changes.rb$}) |
|||
|
|||
# USEFUL for tailwind changes!!!! |
|||
config.watch %r{app/assets/builds/tailwind.css}, reload: :always |
|||
|
|||
# Rk: prevent any reload from files ending with '.tailwind.css' |
|||
# Tailwind CSS |
|||
# First rule prevents reloading from asset files ending with '.tailwind.css' |
|||
config.watch %r{(app|vendor)/(assets|javascript)/.+\.(css|js|html|png|jpg)(?<!tailwind\.css)$}, reload: :always |
|||
config.watch %r{app/assets/builds/tailwind.css}, reload: :always |
|||
|
|||
# Default watched folders & files |
|||
config.watch %r{app/views/.+\.(erb|haml|slim)$} |
|||
|
@ -0,0 +1,21 @@ |
|||
RailsSemanticLogger::Rack::Logger.logger.level = :info # useful for remaining log like "[Rack::Log] Started..." |
|||
SemanticLogger.clear_appenders! |
|||
|
|||
# Zeitwerk reload message |
|||
Rails.autoloaders.main.on_load('ApplicationController') { SemanticLogger[:Zeitwerk].debug('reload!') } |
|||
|
|||
all_notifications = { |
|||
action_controller: %i[start_processing process_action redirect_to], |
|||
action_view: %i[render_partial render_template render_collection render_layout], |
|||
active_record: %i[sql strict_loading instantiation start_transaction transaction] |
|||
} |
|||
|
|||
Rails.configuration.after_initialize do |
|||
all_notifications.each do |event_group, hooks| |
|||
hooks.each { |hook| ActiveSupport::Notifications.unsubscribe("#{hook}.#{event_group}") } |
|||
end |
|||
|
|||
ActiveSupport::Notifications.subscribe('rolling.live_constant') do |event| |
|||
SemanticLogger[:live_notifications].warn('new event', event.payload) # FIXME: to be continued... |
|||
end |
|||
end |
@ -1,104 +0,0 @@ |
|||
require 'dotenv' |
|||
|
|||
module Hot |
|||
# Hot Live Constants |
|||
module Constants |
|||
include SemanticLogger::Loggable |
|||
HOTENV = {} # rubocop:disable Style/MutableConstant |
|||
LISTENERS = {} # rubocop:disable Style/MutableConstant |
|||
|
|||
class << self |
|||
def initialize |
|||
@old_definitions = {} |
|||
@hot_definitions = {} |
|||
|
|||
logger.level = :fatal |
|||
load_definitions |
|||
load_values |
|||
logger.level = :info |
|||
end |
|||
|
|||
def load_definitions |
|||
logger.debug('--load_definitions3') |
|||
|
|||
new_definitions = Dotenv.parse('.env.sample') |
|||
|
|||
definitions_to_actualize = {} |
|||
@old_definitions.merge(new_definitions) do |key, old_value, new_value| |
|||
definitions_to_actualize[key] = new_value if new_value != old_value |
|||
end |
|||
definitions_to_actualize.each { |key, value| define_method(:actualize, key, value) } |
|||
|
|||
@old_definitions = new_definitions |
|||
|
|||
definitions_to_remove = @hot_definitions.except(*new_definitions.keys) |
|||
definitions_to_remove.each_pair do |key, dkey| |
|||
logger.info("remove method <#{dkey}>") |
|||
singleton_class.undef_method(dkey) |
|||
@hot_definitions.delete(key) |
|||
end |
|||
|
|||
definitions_to_add = new_definitions.except(*@hot_definitions.keys) |
|||
definitions_to_add.each { |key, value| define_method(:add, key, value) } |
|||
end |
|||
|
|||
def load_values |
|||
logger.debug('--load_values') |
|||
|
|||
new_env = Dotenv.parse |
|||
constants_to_delete = HOTENV.except(*new_env.keys) |
|||
constants_to_delete.each do |name, _| |
|||
# FIXME: default should read default and type from @old_definitions |
|||
type, default = @old_definitions[name] |
|||
logger.info("constant <#{name}> reverts to default value <#{default}> of type <#{type}>") |
|||
end |
|||
|
|||
constants_to_add = new_env.except(*HOTENV.keys) |
|||
constants_to_add.each do |constant| |
|||
logger.info("constant to add <#{constant}>") |
|||
end |
|||
|
|||
HOTENV.replace new_env |
|||
LISTENERS.each_pair { |k, b| perform_change(k, b) } |
|||
end |
|||
|
|||
def on_change(key, &block) |
|||
LISTENERS[key.downcase.to_sym] = block |
|||
perform_change(key, block) |
|||
end |
|||
|
|||
private |
|||
|
|||
def define_method(mode, key, value) |
|||
dkey = key.downcase |
|||
method = infer_method_from_value(value) |
|||
|
|||
inferred_type = method.name.to_s.split('_')[1] |
|||
logger.info("#{mode} method <#{dkey}> of type <#{inferred_type}> with default value <#{value}> ") |
|||
|
|||
singleton_class.define_method(dkey) { method.call(key, value) } |
|||
@hot_definitions.store(key, dkey) |
|||
end |
|||
|
|||
def perform_change(key, block) |
|||
old_value = nil # TODO: remember last previous value |
|||
new_value = method(key).call |
|||
block.call(new_value, old_value) |
|||
end |
|||
|
|||
def load_boolean(key, default) = HOTENV.fetch(key, default).to_s.downcase == 'true' |
|||
def load_integer(key, default) = HOTENV.fetch(key, default).to_i |
|||
def load_string(key, default) = HOTENV.fetch(key, default) |
|||
|
|||
def infer_method_from_value(value) |
|||
case value.downcase |
|||
when /^(true|false)$/ then method(:load_boolean) |
|||
when /^\d+$/ then method(:load_integer) |
|||
else method(:load_string) |
|||
end |
|||
end |
|||
end |
|||
|
|||
initialize # done once on first require, cause this is just a module (not a class!) |
|||
end |
|||
end |
@ -1,8 +0,0 @@ |
|||
module Hot |
|||
# Hot Live constants |
|||
class Live |
|||
def initialize |
|||
puts 'Hot Live constants initialized' |
|||
end |
|||
end |
|||
end |
@ -0,0 +1,13 @@ |
|||
module Live |
|||
# My live constants |
|||
module Constants |
|||
extend Definable |
|||
|
|||
STIMULUS_DEBUG = boolean false |
|||
ACTION_VIEW = boolean true |
|||
ACTION_CONTROLLER = boolean true |
|||
|
|||
MY_INTEGER = integer 8 |
|||
MY_STRING = string 'titi' |
|||
end |
|||
end |
@ -0,0 +1,118 @@ |
|||
require 'dotenv' |
|||
|
|||
$definable_thread_group ||= ThreadGroup.new |
|||
|
|||
module Live |
|||
# offers typed constant defintions with default value, by using lots of introspecting... |
|||
module Definable |
|||
MAIN_CSS = 'app/assets/stylesheets/application.css'.freeze # useful to trigger :reload_all from RailsLiveReload |
|||
|
|||
def integer(default = 0) = define_type_from_callee(caller[0], :integer, default) |
|||
def boolean(default = true) = define_type_from_callee(caller[0], :boolean, default) # rubocop:disable Style/OptionalBooleanParameter |
|||
def string(default = '') = define_type_from_callee(caller[0], :string, default) |
|||
|
|||
def reload_from_env |
|||
logger.debug('reload from env') |
|||
|
|||
@env_values = Dotenv.parse(*Dotenv::Rails.files) |
|||
changes = 0 |
|||
prefix = 'Constant' |
|||
env_values.each_pair do |constant, raw| |
|||
next unless definitions.include?(constant) |
|||
|
|||
value = typed_value(definitions[constant][:type], raw, definitions[constant][:default]) |
|||
next unless value != definitions[constant][:value] |
|||
|
|||
define_value(constant, value) |
|||
logger.warn "#{prefix} overriden from environment:#{Semantic::AnsiColors::CLEAR} #{constant} = #{value.ai}" |
|||
changes += 1 |
|||
end |
|||
definitions.except(*env_values.keys).each_pair do |constant, options| |
|||
default = options[:default] |
|||
next unless options[:value] != default |
|||
|
|||
define_value(constant, default) |
|||
logger.warn "#{prefix} restored:#{Semantic::AnsiColors::CLEAR} #{constant} = #{default.ai}" |
|||
changes += 1 |
|||
end |
|||
|
|||
return unless changes.positive? |
|||
|
|||
# TODO: ... |
|||
# changes=[] |
|||
# changes << {kind: :overriden, constant: 'ACTION_VIEW', type :boolean, old_value: false, new_value:false} |
|||
# changes << {kind: :restored, constant: 'ACTION_VIEW', type :boolean, old_value: false, new_value:false} |
|||
# ActiveSupport::Notifications.instrument 'rolling.live_constant', this: changes |
|||
|
|||
return unless RailsLiveReload.watcher |
|||
|
|||
FileUtils.touch(MAIN_CSS) # triggering RailsLiveReload |
|||
end |
|||
|
|||
private |
|||
|
|||
def logger = @logger ||= SemanticLogger[self] |
|||
def env_values = @env_values ||= Dotenv.parse |
|||
def definitions = @definitions ||= {} |
|||
|
|||
def define_value(constant, value) |
|||
definitions[constant][:value] = value |
|||
remove_const(constant) |
|||
const_set(constant, value) |
|||
end |
|||
|
|||
# origin (or caller[0]) helps fetching the constant name from source code introspection |
|||
def define_type_from_callee(origin, type, default) |
|||
@@class_origin ||= self # rubocop:disable Style/ClassVars |
|||
@listener ||= start_listener |
|||
|
|||
file, line = origin.split(':') |
|||
constant = introspect_constant_from_file(file, line.to_i - 1) |
|||
raw_value = env_values.fetch(constant, nil) |
|||
value = typed_value(type, raw_value, default) |
|||
definitions[constant] = { type:, default:, value: } |
|||
|
|||
# logger.debug('new definitions', definitions) |
|||
value |
|||
end |
|||
|
|||
def start_listener |
|||
$definable_thread_group.list.each(&:kill) |
|||
$definable_thread_group.add(Thread.new do |
|||
listener = Listen.to(Rails.root, only: /^\.env\.?/) do |
|||
@@class_origin.reload_from_env |
|||
rescue StandardError |
|||
nil |
|||
end |
|||
listener.start |
|||
end) |
|||
end |
|||
|
|||
def typed_value(type, raw, default) |
|||
return default if raw.nil? |
|||
|
|||
case type |
|||
when :integer then raw.to_i |
|||
when :boolean then raw.upcase == 'TRUE' |
|||
else raw |
|||
end |
|||
end |
|||
|
|||
# returns current directory of this source code |
|||
def dir_source_location |
|||
return @dir_source_location if defined?(@dir_source_location) |
|||
|
|||
*paths, _ = Live.const_source_location(:Definable).first.split('/') |
|||
@dir_source_location = paths.join('/') |
|||
end |
|||
|
|||
def introspect_constant_from_file(file, line) |
|||
*dir_file, _ = file.split('/') |
|||
dir_file = dir_file.join('/') |
|||
raise "unexpected directory: #{dir_file} != #{dir_source_location}" unless dir_file == dir_source_location |
|||
|
|||
@lines ||= File.readlines(file) # cached source code |
|||
@lines[line].match(/\s*(.\w+)/)[1] # TODO: should be uppercase! |
|||
end |
|||
end |
|||
end |
@ -0,0 +1,17 @@ |
|||
class MonkeyPatcher |
|||
class << self |
|||
MONKEY_PATCHES = Rails.root.join('lib', 'monkey_patches') |
|||
def run(&) |
|||
patches = Dir.glob(MONKEY_PATCHES.join('**', '*.rb')) |
|||
patches.each do |patch| |
|||
file = Pathname.new(patch).relative_path_from(MONKEY_PATCHES).to_s |
|||
next if file == 'monkey_patcher.rb' # filter off its own file! |
|||
|
|||
if !block_given? || yield(file) |
|||
puts "🐵 patching... #{file}" |
|||
require file |
|||
end |
|||
end |
|||
end |
|||
end |
|||
end |
@ -1,76 +0,0 @@ |
|||
module Semantic |
|||
# use the Zeitwerk autoloader to reattach_appender for development autoreloading feature |
|||
class DevLoader |
|||
def initialize(session_key) |
|||
@session_key = session_key |
|||
@subscribers = {} |
|||
|
|||
RailsSemanticLogger::ActionController::LogSubscriber.logger.level = :fatal # useful for remanent Rack::Log started |
|||
|
|||
launch |
|||
end |
|||
|
|||
def launch |
|||
once_and_reload do |
|||
append_ansi_formatter |
|||
|
|||
Semantic::NotificationUtil.clear_subscribers(/\.action_controller$/) |
|||
Semantic::NotificationUtil.clear_subscribers(/\.action_view$/) |
|||
reset_subscribers |
|||
|
|||
register_action_controller |
|||
register_action_view |
|||
end |
|||
end |
|||
|
|||
private |
|||
|
|||
def once_and_reload(&) |
|||
yield |
|||
Rails.autoloaders.main.on_load('ApplicationController', &) |
|||
end |
|||
|
|||
def append_ansi_formatter |
|||
SemanticLogger.clear_appenders! |
|||
formatter = Semantic::AnsiFormatter.new |
|||
SemanticLogger.add_appender(io: $stdout, |
|||
formatter:, |
|||
filter: ->(log) { !formatter.reject(log) }) |
|||
end |
|||
|
|||
def register_action_controller |
|||
sub_instance = Semantic::Subscribers::ActionController.new(@session_key) |
|||
register_hook(sub_instance, :start_processing) |
|||
register_hook(sub_instance, :process_action, :finish_processing) |
|||
register_hook(sub_instance, :redirect_to) |
|||
%i[send_file send_data halted_callback unpermitted_parameters send_stream write_fragment |
|||
read_fragment expire_fragment exist_fragment?].each do |hook| |
|||
register_hook(sub_instance, hook, :any_hook) |
|||
end |
|||
end |
|||
|
|||
def register_action_view |
|||
sub_instance = Semantic::Subscribers::ActionView.new |
|||
%i[render_template render_partial render_collection render_layout].each do |hook| |
|||
register_hook(sub_instance, hook) |
|||
end |
|||
end |
|||
|
|||
def register_hook(sub_instance, hook, method = hook) |
|||
@subscribers[sub_instance.class] ||= [] |
|||
@subscribers[sub_instance.class] << ActiveSupport::Notifications.subscribe("#{hook}.#{sub_instance.event_group}") do |event| |
|||
sub_instance.send(method, event) |
|||
end |
|||
end |
|||
|
|||
def reset_subscribers |
|||
return if @subscribers.empty? |
|||
|
|||
@subscribers.each_pair do |clazz, subs| |
|||
# puts "reset #{subs.size} subscribers for class <#{clazz}>" |
|||
subs.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) } |
|||
subs.clear |
|||
end |
|||
end |
|||
end |
|||
end |
@ -1,39 +0,0 @@ |
|||
module Semantic |
|||
module NotificationUtil |
|||
class << self |
|||
# pattern could be either a string 'start_processing.action_controller' or a regex /\.action_controller$/ |
|||
# FIXME: weird behaviour, order impact!!!! |
|||
# For instance: |
|||
# OK |
|||
# NotificationUtil.clear_subscribers(/\.action_controller$/) |
|||
# NotificationUtil.clear_subscribers(/\.action_view$/) |
|||
# NOPE |
|||
# NotificationUtil.clear_subscribers(/\.action_view$/) |
|||
# NotificationUtil.clear_subscribers(/\.action_controller$/) |
|||
def clear_subscribers(pattern) |
|||
ActiveSupport::LogSubscriber.subscribers.each { |sub| unattach(sub, pattern) } |
|||
end |
|||
|
|||
private |
|||
|
|||
def subscriber_patterns(subscriber) |
|||
subscriber.patterns.respond_to?(:keys) ? subscriber.patterns.keys : subscriber.patterns |
|||
end |
|||
|
|||
def unattach(subscriber, pattern) |
|||
subscriber_patterns(subscriber).each do |sub_pattern| |
|||
ActiveSupport::Notifications.notifier.listeners_for(sub_pattern).each do |sub| |
|||
next unless sub.instance_variable_get(:@delegate) == subscriber |
|||
next unless pattern.match(sub_pattern) |
|||
|
|||
puts "FOUND subscriber=#{subscriber} for sub_pattern=#{sub_pattern} with logger #{subscriber.logger.name}" |
|||
puts subscriber.class.module_parent.const_source_location(subscriber.class.to_s)&.first |
|||
|
|||
ActiveSupport::Notifications.unsubscribe(sub) |
|||
end |
|||
end |
|||
# ActiveSupport::LogSubscriber.subscribers.delete(subscriber) |
|||
end |
|||
end |
|||
end |
|||
end |
Write
Preview
Loading…
Cancel
Save
Reference in new issue