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.

130 lines
4.3 KiB

  1. require 'dotenv'
  2. $definable_thread_group ||= ThreadGroup.new
  3. module Live
  4. # offers typed constant defintions with default value, by using lots of introspecting...
  5. module Definable
  6. MAIN_CSS = 'app/assets/stylesheets/application.css'.freeze # useful to trigger :reload_all from RailsLiveReload
  7. def integer(default = 0) = define_type_from_callee(caller[0], :integer, default)
  8. def boolean(default = true) = define_type_from_callee(caller[0], :boolean, default) # rubocop:disable Style/OptionalBooleanParameter
  9. def string(default = '') = define_type_from_callee(caller[0], :string, default)
  10. def reload_from_env
  11. logger.debug('reload from env')
  12. changes = []
  13. new_env_values = env_values(cached: false)
  14. new_env_keys = new_env_values.keys
  15. new_env_values.each_pair { |constant, raw| changes << override(constant, raw) }
  16. definitions.except(*new_env_keys).each_pair { |constant, definition| changes << restore(constant, definition) }
  17. changes.compact!
  18. trigger_rolling_event(changes) if changes.any?
  19. end
  20. private
  21. def trigger_rolling_event(changes)
  22. ActiveSupport::Notifications.instrument('rolling.live_constant', changes:)
  23. FileUtils.touch(MAIN_CSS) if defined?(RailsLiveReload) # triggering RailsLiveReload
  24. rescue StandardError => e
  25. logger.error(e)
  26. end
  27. def override(constant, raw)
  28. return unless definitions.include?(constant)
  29. type = definitions[constant][:type]
  30. new_value = typed_value(type, raw, definitions[constant][:default])
  31. old_value = definitions[constant][:value]
  32. return if new_value == old_value
  33. define_value(constant, new_value)
  34. logger.warn "Constant overriden from environment:#{Semantic::AnsiColors::CLEAR} #{constant} = #{new_value.ai}"
  35. { kind: :overriden, constant:, type:, old_value:, new_value: }
  36. end
  37. def restore(constant, definition)
  38. new_value = definition[:default]
  39. old_value = definition[:value]
  40. return if old_value == new_value
  41. type = definition[:type]
  42. define_value(constant, new_value)
  43. logger.warn "Constant restored:#{Semantic::AnsiColors::CLEAR} #{constant} = #{new_value.ai}"
  44. { kind: :restored, constant:, type:, old_value:, new_value: }
  45. end
  46. def env_values(cached: true)
  47. return @env_values if @env_values && cached
  48. @env_values = Dotenv.parse(*Dotenv::Rails.files)
  49. end
  50. def logger = @logger ||= SemanticLogger[self]
  51. def definitions = @definitions ||= {}
  52. def define_value(constant, value)
  53. definitions[constant][:value] = value
  54. remove_const(constant)
  55. const_set(constant, value)
  56. end
  57. # origin (or caller[0]) helps fetching the constant name from source code introspection
  58. def define_type_from_callee(origin, type, default)
  59. @@class_origin ||= self # rubocop:disable Style/ClassVars
  60. @listener ||= start_listener
  61. file, line = origin.split(':')
  62. constant = introspect_constant_from_file(file, line.to_i - 1)
  63. raw_value = env_values.fetch(constant, nil)
  64. value = typed_value(type, raw_value, default)
  65. definitions[constant] = { type:, default:, value: }
  66. # logger.debug('new definitions', definitions)
  67. value
  68. end
  69. def start_listener
  70. $definable_thread_group.list.each(&:kill)
  71. $definable_thread_group.add(Thread.new do
  72. listener = Listen.to(Rails.root, only: /^\.env\.?/) do
  73. @@class_origin.reload_from_env
  74. rescue StandardError
  75. nil
  76. end
  77. listener.start
  78. end)
  79. end
  80. def typed_value(type, raw, default)
  81. return default if raw.nil?
  82. case type
  83. when :integer then raw.to_i
  84. when :boolean then raw.upcase == 'TRUE'
  85. else raw
  86. end
  87. end
  88. # returns current directory of this source code
  89. def dir_source_location
  90. return @dir_source_location if defined?(@dir_source_location)
  91. *paths, _ = Live.const_source_location(:Definable).first.split('/')
  92. @dir_source_location = paths.join('/')
  93. end
  94. def introspect_constant_from_file(file, line)
  95. *dir_file, _ = file.split('/')
  96. dir_file = dir_file.join('/')
  97. raise "unexpected directory: #{dir_file} != #{dir_source_location}" unless dir_file == dir_source_location
  98. @lines ||= File.readlines(file) # cached source code
  99. @lines[line].match(/\s*(.\w+)/)[1] # TODO: should be uppercase!
  100. end
  101. end
  102. end