You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
141 lines
4.9 KiB
141 lines
4.9 KiB
require 'dotenv'
|
|
|
|
# DEFINABLE_THREAD_GROUP ||= ThreadGroup.new
|
|
DEFINABLE_LISTENERS ||= [] # rubocop:disable Lint/OrAssignmentToConstant,Style/MutableConstant
|
|
|
|
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')
|
|
|
|
changes = []
|
|
new_env_values = env_values(cached: false)
|
|
new_env_keys = new_env_values.keys
|
|
|
|
new_env_values.each_pair { |constant, raw| changes << override(constant, raw) }
|
|
definitions.except(*new_env_keys).each_pair { |constant, definition| changes << restore(constant, definition) }
|
|
changes.compact!
|
|
|
|
trigger_rolling_event(changes) if changes.any?
|
|
end
|
|
|
|
private
|
|
|
|
def trigger_rolling_event(changes)
|
|
logger.info ' ', dimensions: Semantic::FancyDimensions.new(rails: '╔═╗', before: 1)
|
|
changes.each do |change|
|
|
value = change[:new_value].ai
|
|
logger.warn "Constant #{change[:kind]}:#{Semantic::AnsiColors::CLEAR} #{change[:constant]} = #{value}"
|
|
end
|
|
logger.info ' ', dimensions: Semantic::FancyDimensions.new(rails: '╚═╝')
|
|
|
|
ActiveSupport::Notifications.instrument('rolling.live_constant', changes:)
|
|
FileUtils.touch(MAIN_CSS) if defined?(RailsLiveReload) # triggering RailsLiveReload
|
|
rescue StandardError => e
|
|
logger.error(e)
|
|
end
|
|
|
|
def override(constant, raw)
|
|
return unless definitions.include?(constant)
|
|
|
|
type = definitions[constant][:type]
|
|
new_value = typed_value(type, raw, definitions[constant][:default])
|
|
old_value = definitions[constant][:value]
|
|
return if new_value == old_value
|
|
|
|
define_value(constant, new_value)
|
|
{ kind: :overriden, constant:, type:, old_value:, new_value: }
|
|
end
|
|
|
|
def restore(constant, definition)
|
|
new_value = definition[:default]
|
|
old_value = definition[:value]
|
|
return if old_value == new_value
|
|
|
|
type = definition[:type]
|
|
define_value(constant, new_value)
|
|
{ kind: :restored, constant:, type:, old_value:, new_value: }
|
|
end
|
|
|
|
def env_values(cached: true)
|
|
return @env_values if @env_values && cached
|
|
|
|
@env_values = Dotenv.parse(*Dotenv::Rails.files)
|
|
end
|
|
|
|
def logger = @logger ||= SemanticLogger[to_s.underscore]
|
|
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 if defined?(Listen)
|
|
|
|
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 definition #{constant}:#{definitions[constant]}")
|
|
value
|
|
end
|
|
|
|
def start_listener
|
|
DEFINABLE_LISTENERS.each(&:stop)
|
|
DEFINABLE_LISTENERS.clear
|
|
listener = Listen.to(Rails.root, only: /^\.env\.?/) do
|
|
@@class_origin.reload_from_env
|
|
rescue StandardError => e
|
|
logger.error('unable to reload from env', e)
|
|
end
|
|
listener.start
|
|
DEFINABLE_LISTENERS << listener
|
|
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
|
|
constant = @lines[line].match(/\s*(.\w+)/)[1]
|
|
unless constant.upcase == constant
|
|
backtrace = ["#{file}:#{line + 1}"]
|
|
raise ArgumentError, "unexpected case: a definable constant <#{constant}> must be uppercase!", backtrace
|
|
end
|
|
constant
|
|
end
|
|
end
|
|
end
|