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 FileUtils.touch(MAIN_CSS) if defined?(RailsLiveReload) # 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