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